Compare commits

..

16 Commits

Author SHA1 Message Date
a650de79c1 feat(purchase): add expiration time calculations to PurchaseResource and implement related tests 2026-08-26 17:00:17 -03:00
8f4fc39858 feat(env): remove unused purchase expiration variables from .env.example 2026-08-26 16:43:02 -03:00
09554b9f80 feat(purchase): refactor stock reservation expiration handling and update related tests 2026-08-26 16:36:26 -03:00
0d2198a3db Merge branch 'feature/set_reset_password_expiration' into dev 2026-08-26 15:57:15 -03:00
52732da960 feat(auth): use environment variable for password reset expiration 2026-08-26 15:56:08 -03:00
22c6631652 Merge branch 'fix/website_types_favicons' into dev 2026-08-26 14:40:46 -03:00
bbe3cf82f5 feat(bootstrap): expose website type favicon 2026-08-26 14:37:25 -03:00
ff61a3d3b8 feat(tenant): share website type favicon attachment 2026-08-26 14:37:17 -03:00
6c5d49bf45 Merge branch 'feature/set_reset_password_expiration' into dev 2026-08-26 11:51:00 -03:00
2596b5df18 test(auth): cover password reset expiration 2026-08-26 11:47:12 -03:00
733064c0dc feat(auth): expire stale password reset codes 2026-08-26 11:47:12 -03:00
99808c1053 feat(auth): persist password reset expiration 2026-08-26 11:47:12 -03:00
e2ad78b1fb Merge branch 'fixes/TL-1886' into dev 2026-08-26 11:40:21 -03:00
8ed8a11b7b fix: define featured group codes in seeders 2026-08-26 11:30:00 -03:00
dc33e5dc09 fix: consolidate purchase confirmation emails 2026-08-26 11:29:53 -03:00
414df97e9d Squashed commit of the following:
commit cd99a6489b
Merge: ebcbbb1 e63a036
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 26 10:12:44 2026 -0300

    Merge branch 'fix/simplify_stock_reservations' of https://gitea.quo.ar/tbianchini/shopit-back into fix/simplify_stock_reservations

commit ebcbbb1774
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:33:39 2026 -0300

    fix(cart): reconcile expired cart mutations

commit 3dd378ed06
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:15 2026 -0300

    docs(stock): describe associated entity expiration

commit 4f705175bd
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:14 2026 -0300

    test(stock): cover propagated reservation expiration

commit 82570fe941
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:09:52 2026 -0300

    refactor(stock): propagate reservation expiration

commit d2a2b323e0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    docs(stock): document terminal reservation recovery

commit ec67623b7b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    test(stock): cover terminal reservation lifecycle

commit f6f138e180
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:41 2026 -0300

    refactor(stock): make expired reservations terminal

commit d612b7a118
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:30 2026 -0300

    docs(stock): describe unified expiration ownership

commit 49f42d4507
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:23 2026 -0300

    test(stock): cover authoritative reservation expiration

commit 1881cc1d4b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:17 2026 -0300

    refactor(stock): make reservation expiration authoritative

commit 00ec37d8a0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:34 2026 -0300

    docs(stock): document centralized reservation flow

commit d9f374c718
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:29 2026 -0300

    test(stock): cover aggregate reservation lifecycle

commit bbfdf8f342
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:23 2026 -0300

    refactor(stock): centralize reservation aggregate

commit 8a0f29bdae
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:33:39 2026 -0300

    fix(cart): reconcile expired cart mutations

commit 198ed400a9
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:15 2026 -0300

    docs(stock): describe associated entity expiration

commit 23d83166e6
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:14 2026 -0300

    test(stock): cover propagated reservation expiration

commit cb090402d0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:09:52 2026 -0300

    refactor(stock): propagate reservation expiration

commit c120b6f7c0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    docs(stock): document terminal reservation recovery

commit 1106fa28b4
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    test(stock): cover terminal reservation lifecycle

commit 896fb81bcf
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:41 2026 -0300

    refactor(stock): make expired reservations terminal

commit 8545fbc645
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:30 2026 -0300

    docs(stock): describe unified expiration ownership

commit dd6dcb708a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:23 2026 -0300

    test(stock): cover authoritative reservation expiration

commit 624b246ff5
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:17 2026 -0300

    refactor(stock): make reservation expiration authoritative

commit 007b95b904
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:34 2026 -0300

    docs(stock): document centralized reservation flow

commit 0b3c8c93be
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:29 2026 -0300

    test(stock): cover aggregate reservation lifecycle

commit ede718e448
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:23 2026 -0300

    refactor(stock): centralize reservation aggregate

commit e63a03601a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:33:39 2026 -0300

    fix(cart): reconcile expired cart mutations

commit 389373ad8c
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:15 2026 -0300

    docs(stock): describe associated entity expiration

commit ace02a3133
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:14 2026 -0300

    test(stock): cover propagated reservation expiration

commit d69a6210f6
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:09:52 2026 -0300

    refactor(stock): propagate reservation expiration

commit 3c0b43fea3
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    docs(stock): document terminal reservation recovery

commit 9b0008626b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    test(stock): cover terminal reservation lifecycle

commit 61861e331a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:41 2026 -0300

    refactor(stock): make expired reservations terminal

commit 2cc9d7dd97
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:30 2026 -0300

    docs(stock): describe unified expiration ownership

commit f982bcead1
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:23 2026 -0300

    test(stock): cover authoritative reservation expiration

commit 7a85e1731d
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:17 2026 -0300

    refactor(stock): make reservation expiration authoritative

commit 6db99e775a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:34 2026 -0300

    docs(stock): document centralized reservation flow

commit e5f7ba3615
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:29 2026 -0300

    test(stock): cover aggregate reservation lifecycle

commit 24bfef431b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:23 2026 -0300

    refactor(stock): centralize reservation aggregate
2026-08-26 10:30:16 -03:00
55 changed files with 546 additions and 250 deletions

View File

@@ -5,9 +5,6 @@ APP_DEBUG=false
APP_URL=http://localhost APP_URL=http://localhost
PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30 PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
PURCHASE_QR_EXPIRATION_MINUTES=15
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
STOCK_RESERVATION_EXPIRATION_MINUTES=30 STOCK_RESERVATION_EXPIRATION_MINUTES=30
FRONTEND_URLS=http://localhost:4200 FRONTEND_URLS=http://localhost:4200
@@ -31,6 +28,7 @@ AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES=30
AUTH_LOGIN_LOCK_MINUTES=15 AUTH_LOGIN_LOCK_MINUTES=15
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10 AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30 AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
AUTH_PASSWORD_RESET_EXPIRATION_MINUTES=60
LOG_CHANNEL=daily LOG_CHANNEL=daily
LOG_STACK=single LOG_STACK=single

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -343,9 +343,9 @@ class StockReservationService
}); });
} }
public function refreshForPurchase(Purchase $purchase, ?Carbon $expiresAt): void public function clearExpirationForReview(Purchase $purchase): void
{ {
DB::transaction(function () use ($purchase, $expiresAt): void { DB::transaction(function () use ($purchase): void {
/** @var Purchase $purchase */ /** @var Purchase $purchase */
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey()); $purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
if ($purchase->stock_reservation_id === null) { if ($purchase->stock_reservation_id === null) {
@@ -363,7 +363,7 @@ class StockReservationService
throw new StockReservationExpiredException; throw new StockReservationExpiredException;
} }
$reservation->update(['expires_at' => $expiresAt]); $reservation->update(['expires_at' => null]);
}); });
} }

View File

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

View File

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

View File

@@ -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->purchaseId, $event->ticketIds);
}
}

View File

@@ -119,55 +119,17 @@ class NotificationMailService
}); });
} }
public function sendPurchasePaid(int $purchaseId): void public function sendPurchaseConfirmed(int $purchaseId): void
{ {
$context = ['purchase_id' => $purchaseId]; $context = ['purchase_id' => $purchaseId];
$this->sendLogged('purchase_paid', $context, function () use ($purchaseId, $context): ?array { $this->sendLogged('purchase_confirmed', $context, function () use ($purchaseId, $context): ?array {
$purchase = Purchase::query() $purchase = Purchase::query()
->with(['tenant', 'user', 'items']) ->with(['tenant', 'user', 'items'])
->find($purchaseId); ->find($purchaseId);
if ($purchase === null) { if ($purchase === null) {
$this->logSkipped('purchase_paid', array_merge($context, [ $this->logSkipped('purchase_confirmed', array_merge($context, [
'reason' => 'purchase_not_found',
'missing_model' => Purchase::class,
]));
return null;
}
$this->mailService
->forTenant($purchase->tenant_codigo)
->send(
$this->recipientFor($purchase),
"Pago confirmado - Compra #{$purchase->getKey()}",
view('mail.notifications.purchase-paid', compact('purchase'))->render(),
);
return [
'tenant_code' => $purchase->tenant_codigo,
'user_id' => $purchase->user_id,
'purchase_status' => $purchase->status,
'purchase_item_count' => $purchase->items->count(),
];
});
}
/** @param array<int, int> $ticketIds */
public function sendTicketsAvailable(int $purchaseId, array $ticketIds): void
{
$context = [
'purchase_id' => $purchaseId,
'requested_ticket_count' => count($ticketIds),
'requested_ticket_ids' => $ticketIds,
];
$this->sendLogged('tickets_available', $context, function () use ($purchaseId, $ticketIds, $context): ?array {
$purchase = Purchase::query()->with(['tenant', 'user'])->find($purchaseId);
if ($purchase === null) {
$this->logSkipped('tickets_available', array_merge($context, [
'reason' => 'purchase_not_found', 'reason' => 'purchase_not_found',
'missing_model' => Purchase::class, 'missing_model' => Purchase::class,
])); ]));
@@ -177,41 +139,34 @@ class NotificationMailService
/** @var Collection<int, Ticket> $tickets */ /** @var Collection<int, Ticket> $tickets */
$tickets = Ticket::query() $tickets = Ticket::query()
->where('source_purchase_id', $purchase->getKey())
->where('tenant_code', $purchase->tenant_codigo) ->where('tenant_code', $purchase->tenant_codigo)
->where('user_id', $purchase->user_id)
->whereKey($ticketIds)
->with(TicketPresentationResolver::RELATIONS) ->with(TicketPresentationResolver::RELATIONS)
->get(); ->get();
$attachments = $tickets->isEmpty()
if ($tickets->isEmpty()) { ? []
$this->logSkipped('tickets_available', array_merge($context, [ : [[
'reason' => 'tickets_not_found', 'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets),
'missing_model' => Ticket::class, 'name' => $this->ticketPdfService->filename($tickets),
'tenant_code' => $purchase->tenant_codigo, 'mime' => 'application/pdf',
'user_id' => $purchase->user_id, ]];
]));
return null;
}
$this->mailService $this->mailService
->forTenant($purchase->tenant_codigo) ->forTenant($purchase->tenant_codigo)
->send( ->send(
$this->recipientFor($purchase), $this->recipientFor($purchase),
'Tus tickets ya están disponibles', "Compra confirmada - Compra #{$purchase->getKey()}",
view('mail.notifications.tickets-available', compact('purchase', 'tickets'))->render(), view('mail.notifications.purchase-confirmed', compact('purchase', 'tickets'))->render(),
attachments: [[ attachments: $attachments,
'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets),
'name' => $this->ticketPdfService->filename($tickets),
'mime' => 'application/pdf',
]],
); );
return [ return [
'tenant_code' => $purchase->tenant_codigo, 'tenant_code' => $purchase->tenant_codigo,
'user_id' => $purchase->user_id, 'user_id' => $purchase->user_id,
'sent_ticket_count' => $tickets->count(), 'purchase_status' => $purchase->status,
'sent_ticket_ids' => $tickets->modelKeys(), 'purchase_item_count' => $purchase->items->count(),
'ticket_count' => $tickets->count(),
'ticket_ids' => $tickets->modelKeys(),
]; ];
}); });
} }

View File

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

View File

@@ -93,7 +93,6 @@ class PurchaseController extends Controller
PaymentIntentRequest $request, PaymentIntentRequest $request,
Tenant $tenant, Tenant $tenant,
Purchase $compra, Purchase $compra,
CheckoutService $checkoutService,
PurchaseStateGuard $purchaseState, PurchaseStateGuard $purchaseState,
): JsonResponse { ): JsonResponse {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
@@ -103,7 +102,6 @@ class PurchaseController extends Controller
: null; : null;
$updated = DB::transaction(function () use ( $updated = DB::transaction(function () use (
$checkoutService,
$compra, $compra,
$method, $method,
$purchaseState, $purchaseState,
@@ -142,12 +140,6 @@ class PurchaseController extends Controller
$purchaseUpdate['transfer_payer_dni'] = $transferPayerDni; $purchaseUpdate['transfer_payer_dni'] = $transferPayerDni;
} }
$purchase->update($purchaseUpdate); $purchase->update($purchaseUpdate);
$checkoutService->refreshReservationExpiration(
$purchase,
now()->addMinutes(
max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30)),
),
);
return true; return true;
}); });

View File

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

View File

@@ -70,7 +70,7 @@ class CompleteCheckoutService
$purchase->update([ $purchase->update([
'status' => Purchase::STATUS_IN_REVIEW, 'status' => Purchase::STATUS_IN_REVIEW,
]); ]);
$this->reservations->refreshForPurchase($purchase, null); $this->reservations->clearExpirationForReview($purchase);
return $this->loadPurchase($purchase); return $this->loadPurchase($purchase);
}); });

View File

@@ -2,14 +2,12 @@
namespace App\Domains\Purchase\Services; namespace App\Domains\Purchase\Services;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService; use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService;
use App\Domains\Purchase\Services\Checkout\EditCheckoutService; use App\Domains\Purchase\Services\Checkout\EditCheckoutService;
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService; use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
use App\Domains\Purchase\Services\Checkout\StartCheckoutService; use App\Domains\Purchase\Services\Checkout\StartCheckoutService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
/** /**
@@ -24,7 +22,6 @@ class CheckoutService
private readonly EditCheckoutService $editor, private readonly EditCheckoutService $editor,
private readonly CompleteCheckoutService $completer, private readonly CompleteCheckoutService $completer,
private readonly ReleaseCheckoutService $releaser, private readonly ReleaseCheckoutService $releaser,
private readonly StockReservationService $reservations,
) {} ) {}
/** @param array<string, mixed> $purchaseData */ /** @param array<string, mixed> $purchaseData */
@@ -78,9 +75,4 @@ class CheckoutService
{ {
return $this->releaser->expire($purchase); return $this->releaser->expire($purchase);
} }
public function refreshReservationExpiration(Purchase $purchase, ?Carbon $expiresAt): void
{
$this->reservations->refreshForPurchase($purchase, $expiresAt);
}
} }

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ vigente, vencido o usado, y resuelve sus fechas efectivas de inicio y fin sin pe
1. `Purchase` emite `PurchasePaid` al confirmarse el pago. 1. `Purchase` emite `PurchasePaid` al confirmarse el pago.
2. `GenerateTicketsForPaidPurchase` atiende el evento. 2. `GenerateTicketsForPaidPurchase` atiende el evento.
3. `TicketGeneratorService` crea los tickets requeridos según ítems, cantidades y vigencia. 3. `TicketGeneratorService` crea los tickets requeridos según ítems, cantidades y vigencia.
4. El flujo puede emitir disponibilidad para que `Notification` informe al comprador. 4. `Notification` envía la confirmación de compra después de la generación y adjunta los tickets cuando existen.
## Endpoints ## Endpoints

View File

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

View File

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

View File

@@ -2,10 +2,4 @@
return [ return [
'checkout_expiration_minutes' => (int) env('PURCHASE_CHECKOUT_EXPIRATION_MINUTES', 30), 'checkout_expiration_minutes' => (int) env('PURCHASE_CHECKOUT_EXPIRATION_MINUTES', 30),
'payment_expiration_minutes' => [
'qr' => (int) env('PURCHASE_QR_EXPIRATION_MINUTES', 15),
'telepagos' => (int) env('PURCHASE_TELEPAGOS_EXPIRATION_MINUTES', 30),
'transfer' => (int) env('PURCHASE_TRANSFER_EXPIRATION_MINUTES', 1440),
],
]; ];

View File

@@ -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');
});
}
};

View File

@@ -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.
}
};

View File

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

View File

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

View File

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

View File

@@ -36,6 +36,7 @@ class WebsiteTypeSeeder extends Seeder
...self::PRESENTATION, ...self::PRESENTATION,
'site_logo' => $this->onTicketLogo(), 'site_logo' => $this->onTicketLogo(),
'footer_logo' => $this->onTicketFooterLogo(), 'footer_logo' => $this->onTicketFooterLogo(),
'favicon' => $this->onTicketFavicon(),
], ],
); );
@@ -70,6 +71,7 @@ class WebsiteTypeSeeder extends Seeder
...self::PRESENTATION, ...self::PRESENTATION,
'site_logo' => $this->onTicketLogo(), 'site_logo' => $this->onTicketLogo(),
'footer_logo' => $this->onTicketFooterLogo(), 'footer_logo' => $this->onTicketFooterLogo(),
'favicon' => $shopIt->favicon()->firstOrFail()->key,
], ],
); );
@@ -175,4 +177,21 @@ class WebsiteTypeSeeder extends Seeder
true, true,
); );
} }
private function onTicketFavicon(): UploadedFile
{
$path = public_path('images/website_types/onticket_favicon.svg');
if (! file_exists($path)) {
throw new RuntimeException("OnTicket favicon not found at path: {$path}");
}
return new UploadedFile(
$path,
'onticket_favicon.svg',
'image/svg+xml',
null,
true,
);
}
} }

View File

@@ -12,6 +12,7 @@ return [
'password_reset_invalid' => 'The password recovery request is invalid or has already been used.', 'password_reset_invalid' => 'The password recovery request is invalid or has already been used.',
'password_updated' => 'Password updated successfully.', 'password_updated' => 'Password updated successfully.',
'reset_code_invalid' => 'The code you entered is invalid.', 'reset_code_invalid' => 'The code you entered is invalid.',
'reset_code_expired' => 'The password recovery code expired. Request a new one.',
'reset_code_valid' => 'Code validated successfully.', 'reset_code_valid' => 'Code validated successfully.',
'invalid_tenant_or_return_url' => 'The tenant or return URL is invalid.', 'invalid_tenant_or_return_url' => 'The tenant or return URL is invalid.',
'request_expired' => 'The authentication request expired. Please try again.', 'request_expired' => 'The authentication request expired. Please try again.',

View File

@@ -12,6 +12,7 @@ return [
'password_reset_invalid' => 'La solicitud de recuperación es inválida o ya fue utilizada.', 'password_reset_invalid' => 'La solicitud de recuperación es inválida o ya fue utilizada.',
'password_updated' => 'Contraseña modificada correctamente.', 'password_updated' => 'Contraseña modificada correctamente.',
'reset_code_invalid' => 'El código ingresado es inválido.', 'reset_code_invalid' => 'El código ingresado es inválido.',
'reset_code_expired' => 'El código de recuperación expiró. Solicitá uno nuevo.',
'reset_code_valid' => 'Código validado correctamente.', 'reset_code_valid' => 'Código validado correctamente.',
'invalid_tenant_or_return_url' => 'El tenant o la URL de retorno no son válidos.', 'invalid_tenant_or_return_url' => 'El tenant o la URL de retorno no son válidos.',
'request_expired' => 'La solicitud de autenticación expiró. Intenta nuevamente.', 'request_expired' => 'La solicitud de autenticación expiró. Intenta nuevamente.',

View 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

View File

@@ -1,4 +1,4 @@
<h1 style="margin: 0 0 20px;">¡Recibimos tu pago!</h1> <h1 style="margin: 0 0 20px;">¡Compra realizada con éxito!</h1>
<p>La compra <strong>#{{ $purchase->id }}</strong> fue confirmada correctamente.</p> <p>La compra <strong>#{{ $purchase->id }}</strong> fue confirmada correctamente.</p>
<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 20px 0;"> <table role="presentation" style="width: 100%; border-collapse: collapse; margin: 20px 0;">
@foreach ($purchase->items as $item) @foreach ($purchase->items as $item)
@@ -10,3 +10,6 @@
@endforeach @endforeach
</table> </table>
<p style="font-size: 18px;"><strong>Total pagado: ${{ number_format((float) $purchase->total, 2, ',', '.') }}</strong></p> <p style="font-size: 18px;"><strong>Total pagado: ${{ number_format((float) $purchase->total, 2, ',', '.') }}</strong></p>
@if ($tickets->isNotEmpty())
<p><strong>Tus tickets ya están disponibles</strong></p>
@endif

View File

@@ -1,8 +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>
<p>{{ $tickets->count() === 1 ? 'El ticket está adjunto' : 'Los tickets están adjuntos' }} a este correo en formato PDF.</p>
<ul style="padding-left: 20px;">
@foreach ($tickets as $ticket)
<li style="margin-bottom: 8px;">{{ $ticket->name }}</li>
@endforeach
</ul>

View File

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

View File

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

View File

@@ -99,6 +99,30 @@ class ResetPasswordControllerTest extends TestCase
$this->assertSame(ResetPasswordAttempt::STATUS_USED, $attempt->fresh()->status); $this->assertSame(ResetPasswordAttempt::STATUS_USED, $attempt->fresh()->status);
} }
public function test_an_expired_validated_attempt_cannot_reset_the_password(): void
{
$user = User::factory()->create([
'email' => 'ada@example.com',
'password' => 'OldSecret!123',
]);
$attempt = $user->resetPasswordAttempts()->create([
'codigo' => '1234',
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
'expires_at' => now()->subSecond(),
]);
$this->postJson('/api/password/reset', [
'email' => 'ada@example.com',
'codigo' => '1234',
'password' => 'NewSecret!456',
'password_confirmation' => 'NewSecret!456',
])->assertUnprocessable()
->assertJsonValidationErrors('codigo');
$this->assertTrue(Hash::check('OldSecret!123', $user->fresh()->password));
$this->assertSame(ResetPasswordAttempt::STATUS_EXPIRED, $attempt->fresh()->status);
}
public function test_it_validates_password_confirmation_and_strength(): void public function test_it_validates_password_confirmation_and_strength(): void
{ {
$this->postJson('/api/password/reset', [ $this->postJson('/api/password/reset', [

View File

@@ -49,6 +49,27 @@ class ValidateResetPasswordAttemptControllerTest extends TestCase
); );
} }
public function test_it_expires_an_attempt_and_returns_the_expired_code_message(): void
{
$user = User::factory()->create(['email' => 'ada@example.com']);
$attempt = $user->resetPasswordAttempts()->create([
'codigo' => '1234',
'expires_at' => now()->subSecond(),
]);
$this->postJson('/api/password/reset-attempts/validate', [
'email' => 'ada@example.com',
'codigo' => '1234',
])->assertUnprocessable()
->assertJsonValidationErrors('codigo')
->assertJsonPath('errors.codigo.0', __('api.auth.reset_code_expired'));
$this->assertSame(
ResetPasswordAttempt::STATUS_EXPIRED,
$attempt->fresh()->status,
);
}
public function test_it_rejects_an_expired_or_already_validated_attempt(): void public function test_it_rejects_an_expired_or_already_validated_attempt(): void
{ {
$user = User::factory()->create(['email' => 'ada@example.com']); $user = User::factory()->create(['email' => 'ada@example.com']);

View File

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

View File

@@ -60,9 +60,9 @@ class TelepagosWebhookTest extends TestCase
->assertJsonValidationErrors(['transfer_payer_dni']); ->assertJsonValidationErrors(['transfer_payer_dni']);
} }
public function test_transfer_payment_intent_persists_transfer_payer_dni_without_replacing_customer_dni(): void public function test_transfer_payment_intent_persists_data_without_extending_checkout_expiration(): void
{ {
config()->set('purchase.payment_expiration_minutes.transfer', 60); config()->set('purchase.checkout_expiration_minutes', 30);
$now = now()->startOfSecond(); $now = now()->startOfSecond();
$this->travelTo($now); $this->travelTo($now);
@@ -106,7 +106,7 @@ class TelepagosWebhookTest extends TestCase
$this->assertDatabaseHas('stock_reservations', [ $this->assertDatabaseHas('stock_reservations', [
'id' => $purchase->stock_reservation_id, 'id' => $purchase->stock_reservation_id,
'status' => 'active', 'status' => 'active',
'expires_at' => $now->copy()->addMinutes(60)->toDateTimeString(), 'expires_at' => $now->copy()->addMinutes(30)->toDateTimeString(),
]); ]);
$this->travelBack(); $this->travelBack();

View 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),
);
}
}

View File

@@ -142,7 +142,7 @@ class NotificationMailServiceTest extends TestCase
}); });
} }
public function test_it_sends_purchase_and_ticket_emails_to_the_purchase_recipient(): void public function test_it_sends_one_purchase_confirmation_with_generated_tickets_attached(): void
{ {
$this->useWebsiteTypeBranding(); $this->useWebsiteTypeBranding();
$purchase = Purchase::query()->create([ $purchase = Purchase::query()->create([
@@ -175,30 +175,23 @@ class NotificationMailServiceTest extends TestCase
$ticket = Ticket::query()->create([ $ticket = Ticket::query()->create([
'tenant_code' => $this->tenant->codigo, 'tenant_code' => $this->tenant->codigo,
'ticket' => fake()->uuid(), 'ticket' => fake()->uuid(),
'source_purchase_id' => $purchase->id,
'source_catalog_item_id' => $catalogItem->id, 'source_catalog_item_id' => $catalogItem->id,
'user_id' => $this->user->id, 'user_id' => $this->user->id,
]); ]);
$service = app(NotificationMailService::class); $service = app(NotificationMailService::class);
$service->sendPurchasePaid($purchase->id); $service->sendPurchaseConfirmed($purchase->id);
$service->sendTicketsAvailable($purchase->id, [$ticket->id]);
Mail::assertSent(Mailable::class, 2); Mail::assertSent(Mailable::class, 1);
Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase): bool { Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase, $ticket): bool {
$mail->assertTo('checkout@example.com');
return $mail->subject === "Pago confirmado - Compra #{$purchase->id}"
&& str_contains($mail->render(), 'Total pagado')
&& str_contains($mail->render(), 'border-top: 4px solid #112233')
&& ! str_contains($mail->render(), 'border-top: 4px solid #ff7006');
});
Mail::assertSent(Mailable::class, function (Mailable $mail) use ($ticket): bool {
$mail->assertTo('checkout@example.com'); $mail->assertTo('checkout@example.com');
$attachment = collect($mail->rawAttachments)->firstWhere('name', "tickets_{$ticket->id}.pdf"); $attachment = collect($mail->rawAttachments)->firstWhere('name', "tickets_{$ticket->id}.pdf");
return $mail->subject === 'Tus tickets ya están disponibles' return $mail->subject === "Compra confirmada - Compra #{$purchase->id}"
&& str_contains($mail->render(), 'Entrada general') && str_contains($mail->render(), '¡Compra realizada con éxito!')
&& str_contains($mail->render(), 'El ticket está adjunto') && str_contains($mail->render(), 'Total pagado')
&& str_contains($mail->render(), 'Tus tickets ya están disponibles')
&& $attachment !== null && $attachment !== null
&& $attachment['options'] === ['mime' => 'application/pdf'] && $attachment['options'] === ['mime' => 'application/pdf']
&& str_starts_with($attachment['data'], '%PDF-') && str_starts_with($attachment['data'], '%PDF-')
@@ -207,6 +200,26 @@ class NotificationMailServiceTest extends TestCase
}); });
} }
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 private function useWebsiteTypeBranding(): void
{ {
$websiteType = WebsiteType::query()->create([ $websiteType = WebsiteType::query()->create([

View File

@@ -2,9 +2,7 @@
namespace Tests\Feature\Notification; namespace Tests\Feature\Notification;
use App\Domains\Notification\Events\TicketsAvailable; use App\Domains\Notification\Listeners\SendPurchaseConfirmedEmail;
use App\Domains\Notification\Listeners\SendPurchasePaidEmail;
use App\Domains\Notification\Listeners\SendTicketsAvailableEmail;
use App\Domains\Notification\Services\NotificationMailService; use App\Domains\Notification\Services\NotificationMailService;
use App\Domains\Purchase\Events\PurchasePaid; use App\Domains\Purchase\Events\PurchasePaid;
use Mockery; use Mockery;
@@ -12,35 +10,21 @@ use Tests\TestCase;
class QueuedNotificationListenerTest extends TestCase class QueuedNotificationListenerTest extends TestCase
{ {
public function test_purchase_paid_email_delegates_with_the_purchase_id(): void public function test_purchase_confirmed_email_delegates_with_the_purchase_id(): void
{ {
$mailService = Mockery::mock(NotificationMailService::class); $mailService = Mockery::mock(NotificationMailService::class);
$mailService->shouldReceive('sendPurchasePaid') $mailService->shouldReceive('sendPurchaseConfirmed')
->once() ->once()
->with(123); ->with(123);
$this->app->instance(NotificationMailService::class, $mailService); $this->app->instance(NotificationMailService::class, $mailService);
(new SendPurchasePaidEmail)->handle(new PurchasePaid(123)); (new SendPurchaseConfirmedEmail)->handle(new PurchasePaid(123));
} }
public function test_tickets_available_email_delegates_with_scalar_identifiers(): void public function test_purchase_paid_event_only_serializes_the_purchase_id(): void
{
$mailService = Mockery::mock(NotificationMailService::class);
$mailService->shouldReceive('sendTicketsAvailable')
->once()
->with(123, [10, 11]);
$this->app->instance(NotificationMailService::class, $mailService);
(new SendTicketsAvailableEmail)->handle(new TicketsAvailable(123, [10, 11]));
}
public function test_email_events_only_serialize_scalar_identifiers(): void
{ {
$purchasePaid = unserialize(serialize(new PurchasePaid(123))); $purchasePaid = unserialize(serialize(new PurchasePaid(123)));
$ticketsAvailable = unserialize(serialize(new TicketsAvailable(123, [10, 11])));
$this->assertSame(123, $purchasePaid->purchaseId); $this->assertSame(123, $purchasePaid->purchaseId);
$this->assertSame(123, $ticketsAvailable->purchaseId);
$this->assertSame([10, 11], $ticketsAvailable->ticketIds);
} }
} }

View File

@@ -796,12 +796,18 @@ class StorePurchaseTest extends TestCase
public function test_it_updates_customer_data_for_a_pending_payment_purchase(): void public function test_it_updates_customer_data_for_a_pending_payment_purchase(): void
{ {
config()->set('purchase.checkout_expiration_minutes', 30);
$now = now()->startOfSecond();
$this->travelTo($now);
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create(); $user = User::factory()->create();
$variant = $this->createVariantForTenant('sonder', 10, '50.00'); $variant = $this->createVariantForTenant('sonder', 10, '50.00');
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 1); $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 1);
$purchase->update(['status' => Purchase::STATUS_PENDING_PAYMENT]); $purchase->update(['status' => Purchase::STATUS_PENDING_PAYMENT]);
$this->travel(10)->minutes();
$this->actingAs($user, 'sanctum') $this->actingAs($user, 'sanctum')
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/customer-data", [ ->patchJson("/api/tenants/sonder/compras/{$purchase->id}/customer-data", [
'dni' => '987654321', 'dni' => '987654321',
@@ -818,6 +824,12 @@ class StorePurchaseTest extends TestCase
'status' => Purchase::STATUS_PENDING_PAYMENT, 'status' => Purchase::STATUS_PENDING_PAYMENT,
'dni' => '987654321', 'dni' => '987654321',
]); ]);
$this->assertDatabaseHas('stock_reservations', [
'id' => $purchase->stock_reservation_id,
'expires_at' => $now->copy()->addMinutes(30)->toDateTimeString(),
]);
$this->travelBack();
} }
public function test_checkout_items_are_immutable_and_editing_routes_are_unavailable(): void public function test_checkout_items_are_immutable_and_editing_routes_are_unavailable(): void

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -69,4 +69,31 @@ class WebsiteTypeServiceTest extends TestCase
'favicon_id' => $favicon->id, 'favicon_id' => $favicon->id,
]); ]);
} }
public function test_it_keeps_a_shared_favicon_when_one_website_type_replaces_it(): void
{
Storage::fake('s3');
$service = app(WebsiteTypeService::class);
$shopIt = $service->create([
'codigo' => 'shopit',
'nombre' => 'ShopIt',
'favicon' => UploadedFile::fake()->image('shared-favicon.png', 32, 32),
]);
$sharedFavicon = $shopIt->favicon()->firstOrFail();
$onTicket = $service->create([
'codigo' => 'onticket',
'nombre' => 'OnTicket',
'favicon' => $sharedFavicon->key,
]);
$service->updateOrCreate(
['codigo' => 'shopit'],
['favicon' => UploadedFile::fake()->image('shopit-favicon.png', 32, 32)],
);
$this->assertSame($sharedFavicon->id, $onTicket->fresh()->favicon_id);
$this->assertDatabaseHas('attachments', ['id' => $sharedFavicon->id]);
Storage::disk('s3')->assertExists($sharedFavicon->path);
}
} }

View File

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

View File

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

View File

@@ -42,31 +42,11 @@ class NotificationMailServiceLoggingTest extends TestCase
'purchase_id' => 123, 'purchase_id' => 123,
'reason' => 'purchase_not_found', 'reason' => 'purchase_not_found',
'missing_model' => Purchase::class, 'missing_model' => Purchase::class,
'email_type' => 'purchase_paid', 'email_type' => 'purchase_confirmed',
], ],
); );
$this->service->sendPurchasePaid(123); $this->service->sendPurchaseConfirmed(123);
}
public function test_missing_purchase_log_includes_the_requested_ticket_ids(): void
{
$this->createEmptyPurchasesTable();
Log::shouldReceive('channel')->once()->with('emails')->andReturnSelf();
Log::shouldReceive('warning')->once()->with(
'Notification email skipped.',
[
'purchase_id' => 123,
'requested_ticket_count' => 2,
'requested_ticket_ids' => [10, 11],
'reason' => 'purchase_not_found',
'missing_model' => Purchase::class,
'email_type' => 'tickets_available',
],
);
$this->service->sendTicketsAvailable(123, [10, 11]);
} }
public function test_it_logs_successful_delivery_with_the_mailer(): void public function test_it_logs_successful_delivery_with_the_mailer(): void
@@ -97,13 +77,13 @@ class NotificationMailServiceLoggingTest extends TestCase
Log::shouldReceive('error')->once()->with( Log::shouldReceive('error')->once()->with(
'Notification email delivery failed.', 'Notification email delivery failed.',
Mockery::on(fn (array $context): bool => $context['purchase_id'] === 123 Mockery::on(fn (array $context): bool => $context['purchase_id'] === 123
&& $context['email_type'] === 'purchase_paid' && $context['email_type'] === 'purchase_confirmed'
&& $context['exception'] === $exception), && $context['exception'] === $exception),
); );
$this->expectExceptionObject($exception); $this->expectExceptionObject($exception);
$this->sendLogged('purchase_paid', ['purchase_id' => 123], function () use ($exception): array { $this->sendLogged('purchase_confirmed', ['purchase_id' => 123], function () use ($exception): array {
throw $exception; throw $exception;
}); });
} }

View File

@@ -0,0 +1,69 @@
<?php
namespace Tests\Unit\Purchase;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Resources\PurchaseResource;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class PurchaseResourceTest extends TestCase
{
public function test_it_exposes_the_remaining_checkout_time_using_the_server_clock(): void
{
$now = now()->startOfSecond();
$this->travelTo($now);
$expiresAt = $now->copy()->addMinutes(12);
$resource = $this->resourceFor(Purchase::STATUS_PENDING_PAYMENT, $expiresAt);
$this->assertTrue($expiresAt->equalTo($resource['expires_at']));
$this->assertSame(720, $resource['expires_in_seconds']);
$this->assertTrue($now->equalTo($resource['server_time']));
$this->travelBack();
}
public function test_it_clamps_an_overdue_checkout_to_zero_seconds(): void
{
$now = now()->startOfSecond();
$this->travelTo($now);
$resource = $this->resourceFor(
Purchase::STATUS_CREATED,
$now->copy()->subSecond(),
);
$this->assertSame(0, $resource['expires_in_seconds']);
$this->travelBack();
}
public function test_it_exposes_null_expiration_after_the_purchase_enters_review(): void
{
$resource = $this->resourceFor(
Purchase::STATUS_IN_REVIEW,
null,
);
$this->assertNull($resource['expires_at']);
$this->assertNull($resource['expires_in_seconds']);
}
/** @return array<string, mixed> */
private function resourceFor(string $status, ?Carbon $expiresAt): array
{
$purchase = (new Purchase)->forceFill([
'status' => $status,
'total' => '0.00',
]);
$purchase->setRelation('stockReservation', (new StockReservation)->forceFill([
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => $expiresAt,
]));
return (new PurchaseResource($purchase))->toArray(Request::create('/'));
}
}