diff --git a/.env.example b/.env.example index 1782db8..271a397 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,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 diff --git a/app/Domains/Bootstrap/Resources/AdminAppBootstrapResource.php b/app/Domains/Bootstrap/Resources/AdminAppBootstrapResource.php index 296e284..63ba328 100644 --- a/app/Domains/Bootstrap/Resources/AdminAppBootstrapResource.php +++ b/app/Domains/Bootstrap/Resources/AdminAppBootstrapResource.php @@ -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), ]; } } diff --git a/app/Domains/Bootstrap/Services/AdminAppBootstrapService.php b/app/Domains/Bootstrap/Services/AdminAppBootstrapService.php index 572579a..743f4a0 100644 --- a/app/Domains/Bootstrap/Services/AdminAppBootstrapService.php +++ b/app/Domains/Bootstrap/Services/AdminAppBootstrapService.php @@ -11,7 +11,7 @@ class AdminAppBootstrapService { return [ 'website_type' => WebsiteType::query() - ->with(['siteLogo', 'footerLogo']) + ->with(['siteLogo', 'footerLogo', 'favicon']) ->where('dominio', $domain) ->firstOrFail(), ]; diff --git a/app/Domains/Bootstrap/Services/ScannerBootstrapService.php b/app/Domains/Bootstrap/Services/ScannerBootstrapService.php index 392c477..4a088a1 100644 --- a/app/Domains/Bootstrap/Services/ScannerBootstrapService.php +++ b/app/Domains/Bootstrap/Services/ScannerBootstrapService.php @@ -11,7 +11,7 @@ class ScannerBootstrapService { return [ 'website_type' => WebsiteType::query() - ->with(['siteLogo', 'footerLogo']) + ->with(['siteLogo', 'footerLogo', 'favicon']) ->where('scanner_domain', $domain) ->firstOrFail(), ]; diff --git a/app/Domains/Integration/Services/MailService.php b/app/Domains/Integration/Services/MailService.php index 6be767d..c264c24 100644 --- a/app/Domains/Integration/Services/MailService.php +++ b/app/Domains/Integration/Services/MailService.php @@ -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 $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' - + {!! $content !!} 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) { diff --git a/app/Domains/MailTest/Mailables/TestMail.php b/app/Domains/MailTest/Mailables/TestMail.php index fd0e1fb..bff67ab 100644 --- a/app/Domains/MailTest/Mailables/TestMail.php +++ b/app/Domains/MailTest/Mailables/TestMail.php @@ -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), ], diff --git a/app/Domains/Notification/Events/TicketsAvailable.php b/app/Domains/Notification/Events/TicketsAvailable.php deleted file mode 100644 index ff308dc..0000000 --- a/app/Domains/Notification/Events/TicketsAvailable.php +++ /dev/null @@ -1,20 +0,0 @@ - $ticketIds - */ - public function __construct( - public readonly Purchase $purchase, - public readonly array $ticketIds, - ) {} -} diff --git a/app/Domains/Notification/Listeners/SendPasswordResetEmail.php b/app/Domains/Notification/Listeners/SendPasswordResetEmail.php index af7d8f3..69a4dfe 100644 --- a/app/Domains/Notification/Listeners/SendPasswordResetEmail.php +++ b/app/Domains/Notification/Listeners/SendPasswordResetEmail.php @@ -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, + ); } } diff --git a/app/Domains/Notification/Listeners/SendPurchasePaidEmail.php b/app/Domains/Notification/Listeners/SendPurchaseConfirmedEmail.php similarity index 76% rename from app/Domains/Notification/Listeners/SendPurchasePaidEmail.php rename to app/Domains/Notification/Listeners/SendPurchaseConfirmedEmail.php index da51393..231fd35 100644 --- a/app/Domains/Notification/Listeners/SendPurchasePaidEmail.php +++ b/app/Domains/Notification/Listeners/SendPurchaseConfirmedEmail.php @@ -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); } } diff --git a/app/Domains/Notification/Listeners/SendTicketsAvailableEmail.php b/app/Domains/Notification/Listeners/SendTicketsAvailableEmail.php deleted file mode 100644 index 53a32a9..0000000 --- a/app/Domains/Notification/Listeners/SendTicketsAvailableEmail.php +++ /dev/null @@ -1,25 +0,0 @@ - */ - public array $backoff = [30, 120, 300]; - - public function handle(TicketsAvailable $event): void - { - app(NotificationMailService::class)->sendTicketsAvailable($event->purchase->getKey(), $event->ticketIds); - } -} diff --git a/app/Domains/Notification/Services/NotificationMailService.php b/app/Domains/Notification/Services/NotificationMailService.php index 6912028..6b5382a 100644 --- a/app/Domains/Notification/Services/NotificationMailService.php +++ b/app/Domains/Notification/Services/NotificationMailService.php @@ -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 $ticketIds */ - public function sendTicketsAvailable(int $purchaseId, array $ticketIds): void - { - $purchase = Purchase::query()->with(['tenant', 'user'])->findOrFail($purchaseId); - /** @var Collection $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 $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 $context + * @param Closure(): (array|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 $context */ + private function logSkipped(string $emailType, array $context): void + { + Log::channel('emails')->warning('Notification email skipped.', array_merge($context, [ + 'email_type' => $emailType, + ])); + } } diff --git a/app/Domains/Notification/documentacion/README.md b/app/Domains/Notification/documentacion/README.md index 6d7dd16..467c0dd 100644 --- a/app/Domains/Notification/documentacion/README.md +++ b/app/Domains/Notification/documentacion/README.md @@ -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. diff --git a/app/Domains/Purchase/Events/PurchasePaid.php b/app/Domains/Purchase/Events/PurchasePaid.php index 3eed891..4b72ab2 100644 --- a/app/Domains/Purchase/Events/PurchasePaid.php +++ b/app/Domains/Purchase/Events/PurchasePaid.php @@ -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) {} } diff --git a/app/Domains/Purchase/Models/Purchase.php b/app/Domains/Purchase/Models/Purchase.php index 9a37c36..447db78 100644 --- a/app/Domains/Purchase/Models/Purchase.php +++ b/app/Domains/Purchase/Models/Purchase.php @@ -193,7 +193,7 @@ class Purchase extends Model 'status' => self::STATUS_PAID, ]); - PurchasePaid::dispatch($this); + PurchasePaid::dispatch($this->getKey()); }); } } diff --git a/app/Domains/Sale/Controllers/AdminApp/SaleController.php b/app/Domains/Sale/Controllers/AdminApp/SaleController.php index fcc37ae..22bcfe1 100644 --- a/app/Domains/Sale/Controllers/AdminApp/SaleController.php +++ b/app/Domains/Sale/Controllers/AdminApp/SaleController.php @@ -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'), + ); + } } diff --git a/app/Domains/Sale/Services/AdminAppSaleExcelService.php b/app/Domains/Sale/Services/AdminAppSaleExcelService.php new file mode 100644 index 0000000..ca88bce --- /dev/null +++ b/app/Domains/Sale/Services/AdminAppSaleExcelService.php @@ -0,0 +1,211 @@ + $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 $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 $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', + }; + } +} diff --git a/app/Domains/Sale/documentacion/README.md b/app/Domains/Sale/documentacion/README.md index 3f6c49b..7446341 100644 --- a/app/Domains/Sale/documentacion/README.md +++ b/app/Domains/Sale/documentacion/README.md @@ -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. diff --git a/app/Domains/Sale/routes/adminapp.php b/app/Domains/Sale/routes/adminapp.php index b803f1e..4d0076b 100644 --- a/app/Domains/Sale/routes/adminapp.php +++ b/app/Domains/Sale/routes/adminapp.php @@ -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'); diff --git a/app/Domains/Tenant/Services/WebsiteTypeService.php b/app/Domains/Tenant/Services/WebsiteTypeService.php index f89d69f..9c57eba 100644 --- a/app/Domains/Tenant/Services/WebsiteTypeService.php +++ b/app/Domains/Tenant/Services/WebsiteTypeService.php @@ -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(); + } } diff --git a/app/Domains/Ticket/Listeners/GenerateTicketsForPaidPurchase.php b/app/Domains/Ticket/Listeners/GenerateTicketsForPaidPurchase.php index 29899d2..3c7d823 100644 --- a/app/Domains/Ticket/Listeners/GenerateTicketsForPaidPurchase.php +++ b/app/Domains/Ticket/Listeners/GenerateTicketsForPaidPurchase.php @@ -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); } } diff --git a/app/Domains/Ticket/Services/TicketPdfService.php b/app/Domains/Ticket/Services/TicketPdfService.php index 34bc681..211a4cf 100644 --- a/app/Domains/Ticket/Services/TicketPdfService.php +++ b/app/Domains/Ticket/Services/TicketPdfService.php @@ -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 $tickets */ public function download(Tenant $tenant, Collection $tickets): Response + { + return $this->pdf($tenant, $tickets)->download($this->filename($tickets)); + } + + /** + * @param Collection $tickets + */ + public function contents(Tenant $tenant, Collection $tickets): string + { + return $this->pdf($tenant, $tickets)->output(); + } + + /** + * @param Collection $tickets + */ + public function filename(Collection $tickets): string + { + return 'tickets_'.$tickets->pluck('id')->implode('_').'.pdf'; + } + + /** + * @param Collection $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 diff --git a/app/Domains/Ticket/documentacion/README.md b/app/Domains/Ticket/documentacion/README.md index c07caf4..a266d30 100644 --- a/app/Domains/Ticket/documentacion/README.md +++ b/app/Domains/Ticket/documentacion/README.md @@ -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 diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f3f8e17..557ef96 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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); diff --git a/composer.json b/composer.json index 6b11461..78ca5d7 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index f9e8e33..8633ccc 100644 --- a/composer.lock +++ b/composer.lock @@ -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" diff --git a/config/logging.php b/config/logging.php index 61a000b..89a1381 100644 --- a/config/logging.php +++ b/config/logging.php @@ -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'), diff --git a/database/migrations/2026_08_26_000000_set_website_type_favicon.php b/database/migrations/2026_08_26_000000_set_website_type_favicon.php new file mode 100644 index 0000000..e3699ed --- /dev/null +++ b/database/migrations/2026_08_26_000000_set_website_type_favicon.php @@ -0,0 +1,96 @@ + */ + 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. + } +}; diff --git a/database/seeders/DesfilePuraTendenciaSeeder.php b/database/seeders/DesfilePuraTendenciaSeeder.php index 54328c2..73dfa82 100644 --- a/database/seeders/DesfilePuraTendenciaSeeder.php +++ b/database/seeders/DesfilePuraTendenciaSeeder.php @@ -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, diff --git a/database/seeders/FiestaFutbolInfantilProductSeeder.php b/database/seeders/FiestaFutbolInfantilProductSeeder.php index 00bea4a..e60ca10 100644 --- a/database/seeders/FiestaFutbolInfantilProductSeeder.php +++ b/database/seeders/FiestaFutbolInfantilProductSeeder.php @@ -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, diff --git a/database/seeders/ProductCatalogFromImagesSeeder.php b/database/seeders/ProductCatalogFromImagesSeeder.php index 84c31cd..b0a2925 100644 --- a/database/seeders/ProductCatalogFromImagesSeeder.php +++ b/database/seeders/ProductCatalogFromImagesSeeder.php @@ -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, diff --git a/database/seeders/WebsiteTypeSeeder.php b/database/seeders/WebsiteTypeSeeder.php index 3b022a4..7db0556 100644 --- a/database/seeders/WebsiteTypeSeeder.php +++ b/database/seeders/WebsiteTypeSeeder.php @@ -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, + ); + } } diff --git a/public/images/website_types/onticket_favicon.svg b/public/images/website_types/onticket_favicon.svg new file mode 100644 index 0000000..30502c6 --- /dev/null +++ b/public/images/website_types/onticket_favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/views/components/mail/branded-layout.blade.php b/resources/views/components/mail/branded-layout.blade.php index c612670..ea137a7 100644 --- a/resources/views/components/mail/branded-layout.blade.php +++ b/resources/views/components/mail/branded-layout.blade.php @@ -1,4 +1,4 @@ -@props(['tenant', 'headerLogoUrl' => null, 'footerLogoUrl' => null]) +@props(['branding', 'headerLogoUrl' => null, 'footerLogoUrl' => null]) @@ -6,7 +6,7 @@ - {{ $tenant->nombre }} + {{ $branding['name'] }} - - + +
- + - @@ -34,11 +34,11 @@ - diff --git a/resources/views/mail/notifications/password-reset.blade.php b/resources/views/mail/notifications/password-reset.blade.php index 1a47a29..2cc4aab 100644 --- a/resources/views/mail/notifications/password-reset.blade.php +++ b/resources/views/mail/notifications/password-reset.blade.php @@ -1,10 +1,10 @@ -

+

Recuperá tu contraseña

@if($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_STAFF_CREATED)

- 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.

@elseif($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_ACCOUNT_LOCKED)

@@ -17,10 +17,10 @@

@endif -

Ingresá este código en {{ $tenant->nombre }}:

+

Ingresá este código en {{ $brand->nombre }}:

-
- +
+ {{ $attempt->codigo }}
@@ -28,7 +28,7 @@ @if($recoveryUrl) diff --git a/resources/views/mail/notifications/purchase-paid.blade.php b/resources/views/mail/notifications/purchase-confirmed.blade.php similarity index 82% rename from resources/views/mail/notifications/purchase-paid.blade.php rename to resources/views/mail/notifications/purchase-confirmed.blade.php index 0479420..de371c1 100644 --- a/resources/views/mail/notifications/purchase-paid.blade.php +++ b/resources/views/mail/notifications/purchase-confirmed.blade.php @@ -1,4 +1,4 @@ -

¡Recibimos tu pago!

+

¡Compra realizada con éxito!

La compra #{{ $purchase->id }} fue confirmada correctamente.

@foreach ($purchase->items as $item) @@ -10,3 +10,6 @@ @endforeach

Total pagado: ${{ number_format((float) $purchase->total, 2, ',', '.') }}

+@if ($tickets->isNotEmpty()) +

Tus tickets ya están disponibles

+@endif diff --git a/resources/views/mail/notifications/tickets-available.blade.php b/resources/views/mail/notifications/tickets-available.blade.php deleted file mode 100644 index ab0bb53..0000000 --- a/resources/views/mail/notifications/tickets-available.blade.php +++ /dev/null @@ -1,7 +0,0 @@ -

Tus tickets ya están disponibles

-

Generamos {{ $tickets->count() }} {{ $tickets->count() === 1 ? 'ticket' : 'tickets' }} para la compra #{{ $purchase->id }}.

-
    - @foreach ($tickets as $ticket) -
  • {{ $ticket->name }}
  • - @endforeach -
diff --git a/resources/views/mail/notifications/welcome.blade.php b/resources/views/mail/notifications/welcome.blade.php index aa541a6..5f0e215 100644 --- a/resources/views/mail/notifications/welcome.blade.php +++ b/resources/views/mail/notifications/welcome.blade.php @@ -1,3 +1,3 @@ -

¡Bienvenido a {{ $tenant->nombre }}!

+

¡Bienvenido a {{ $brand->nombre }}!

Hola {{ $user->nombre_apellido }}, tu cuenta fue creada correctamente.

Ya podés ingresar y comenzar a comprar.

diff --git a/resources/views/mail/test.blade.php b/resources/views/mail/test.blade.php index ba5d695..3cdc2eb 100644 --- a/resources/views/mail/test.blade.php +++ b/resources/views/mail/test.blade.php @@ -1,4 +1,4 @@ - +

Prueba de correo de Shopit

diff --git a/tests/Feature/Catalog/CatalogSchemaTest.php b/tests/Feature/Catalog/CatalogSchemaTest.php index 43147b4..68b2508 100644 --- a/tests/Feature/Catalog/CatalogSchemaTest.php +++ b/tests/Feature/Catalog/CatalogSchemaTest.php @@ -96,6 +96,7 @@ class CatalogSchemaTest extends TestCase $this->assertEqualsCanonicalizing([ 'id', 'tenant_code', + 'code', 'source_type', 'category_id', 'product_layout', diff --git a/tests/Feature/Migrations/SetWebsiteTypeFaviconTest.php b/tests/Feature/Migrations/SetWebsiteTypeFaviconTest.php new file mode 100644 index 0000000..674130f --- /dev/null +++ b/tests/Feature/Migrations/SetWebsiteTypeFaviconTest.php @@ -0,0 +1,61 @@ +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), + ); + } +} diff --git a/tests/Feature/Notification/NotificationMailServiceTest.php b/tests/Feature/Notification/NotificationMailServiceTest.php index a343e1e..bba638e 100644 --- a/tests/Feature/Notification/NotificationMailServiceTest.php +++ b/tests/Feature/Notification/NotificationMailServiceTest.php @@ -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'); + } } diff --git a/tests/Feature/Notification/QueuedNotificationListenerTest.php b/tests/Feature/Notification/QueuedNotificationListenerTest.php new file mode 100644 index 0000000..fbf446f --- /dev/null +++ b/tests/Feature/Notification/QueuedNotificationListenerTest.php @@ -0,0 +1,30 @@ +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); + } +} diff --git a/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php b/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php index 16deae3..22fa727 100644 --- a/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php +++ b/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php @@ -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', diff --git a/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php b/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php index 6fe8fdd..98e5071 100644 --- a/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php +++ b/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php @@ -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); diff --git a/tests/Feature/Seeders/ProductCatalogFromImagesSeederTest.php b/tests/Feature/Seeders/ProductCatalogFromImagesSeederTest.php index 98f5104..cf2d47f 100644 --- a/tests/Feature/Seeders/ProductCatalogFromImagesSeederTest.php +++ b/tests/Feature/Seeders/ProductCatalogFromImagesSeederTest.php @@ -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); diff --git a/tests/Feature/Seeders/WebsiteTypeSeederTest.php b/tests/Feature/Seeders/WebsiteTypeSeederTest.php index c91c003..d286cf6 100644 --- a/tests/Feature/Seeders/WebsiteTypeSeederTest.php +++ b/tests/Feature/Seeders/WebsiteTypeSeederTest.php @@ -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(), diff --git a/tests/Feature/Tenant/BootstrapAdminAppControllerTest.php b/tests/Feature/Tenant/BootstrapAdminAppControllerTest.php index c64940b..9912805 100644 --- a/tests/Feature/Tenant/BootstrapAdminAppControllerTest.php +++ b/tests/Feature/Tenant/BootstrapAdminAppControllerTest.php @@ -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') diff --git a/tests/Feature/Tenant/BootstrapScannerControllerTest.php b/tests/Feature/Tenant/BootstrapScannerControllerTest.php index e4eb18f..c40c9e1 100644 --- a/tests/Feature/Tenant/BootstrapScannerControllerTest.php +++ b/tests/Feature/Tenant/BootstrapScannerControllerTest.php @@ -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') diff --git a/tests/Feature/Tenant/WebsiteTypeServiceTest.php b/tests/Feature/Tenant/WebsiteTypeServiceTest.php index 176f6e8..39cd9a2 100644 --- a/tests/Feature/Tenant/WebsiteTypeServiceTest.php +++ b/tests/Feature/Tenant/WebsiteTypeServiceTest.php @@ -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); + } } diff --git a/tests/Feature/Ticket/TicketGeneratorServiceTest.php b/tests/Feature/Ticket/TicketGeneratorServiceTest.php index a8c937e..e9eda8a 100644 --- a/tests/Feature/Ticket/TicketGeneratorServiceTest.php +++ b/tests/Feature/Ticket/TicketGeneratorServiceTest.php @@ -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}") diff --git a/tests/Unit/Bootstrap/AdminAppBootstrapResourceTest.php b/tests/Unit/Bootstrap/AdminAppBootstrapResourceTest.php index eb142f6..7a01e39 100644 --- a/tests/Unit/Bootstrap/AdminAppBootstrapResourceTest.php +++ b/tests/Unit/Bootstrap/AdminAppBootstrapResourceTest.php @@ -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); } } diff --git a/tests/Unit/Notification/NotificationMailServiceLoggingTest.php b/tests/Unit/Notification/NotificationMailServiceLoggingTest.php new file mode 100644 index 0000000..6733354 --- /dev/null +++ b/tests/Unit/Notification/NotificationMailServiceLoggingTest.php @@ -0,0 +1,112 @@ +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 $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(); + }); + } +} diff --git a/tests/Unit/Notification/SendPasswordResetEmailTest.php b/tests/Unit/Notification/SendPasswordResetEmailTest.php index f52035c..41a5ec7 100644 --- a/tests/Unit/Notification/SendPasswordResetEmailTest.php +++ b/tests/Unit/Notification/SendPasswordResetEmailTest.php @@ -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( diff --git a/tests/Unit/Sale/AdminAppSaleExcelServiceTest.php b/tests/Unit/Sale/AdminAppSaleExcelServiceTest.php new file mode 100644 index 0000000..34676b0 --- /dev/null +++ b/tests/Unit/Sale/AdminAppSaleExcelServiceTest.php @@ -0,0 +1,146 @@ +downloadSales( + $this->tenant(), + collect([$this->sale()]), + 'America/La_Paz', + ); + + $this->assertExcelResponse( + $response, + 'ventas_acme_20260824_135300.xlsx', + function (string $path): void { + $sheet = IOFactory::load($path)->getActiveSheet(); + + $this->assertSame('Ventas', $sheet->getTitle()); + $this->assertSame('Cliente Test', $sheet->getCell('C2')->getValue()); + $this->assertSame('Confirmado', $sheet->getCell('E2')->getValue()); + $this->assertSame(25000.0, $sheet->getCell('F2')->getValue()); + }, + ); + } + + public function test_it_downloads_the_modification_history_as_an_excel_file(): void + { + $sale = $this->sale(); + $admin = (new User)->forceFill([ + 'id' => 10, + 'nombre_apellido' => 'Admin Test', + 'email' => 'admin@example.test', + ]); + $modification = (new ValueChange)->forceFill([ + 'id' => 1, + 'trackable_id' => $sale->id, + 'attribute' => 'status', + 'old_value' => Purchase::STATUS_PENDING_PAYMENT, + 'new_value' => Purchase::STATUS_PAID, + 'changed_at' => now(), + 'actor_type' => 'user', + ]); + $modification->setRelation('trackable', $sale); + $modification->setRelation('user', $admin); + + $response = app(AdminAppSaleExcelService::class)->downloadModifications( + $this->tenant(), + collect([$modification]), + 'America/La_Paz', + ); + + $this->assertExcelResponse( + $response, + 'historial_modificaciones_acme_20260824_135300.xlsx', + function (string $path): void { + $sheet = IOFactory::load($path)->getActiveSheet(); + + $this->assertSame('Modificaciones', $sheet->getTitle()); + $this->assertSame('#15', $sheet->getCell('C2')->getValue()); + $this->assertSame('pending_payment', $sheet->getCell('F2')->getValue()); + $this->assertSame('paid', $sheet->getCell('G2')->getValue()); + $this->assertSame('Admin Test', $sheet->getCell('H2')->getValue()); + }, + ); + } + + /** @param callable(string): void $assertSpreadsheet */ + private function assertExcelResponse( + StreamedResponse $response, + string $filename, + callable $assertSpreadsheet, + ): void { + $this->assertSame( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + $response->headers->get('content-type'), + ); + $this->assertStringContainsString( + "attachment; filename={$filename}", + (string) $response->headers->get('content-disposition'), + ); + + ob_start(); + ($response->getCallback())(); + $contents = ob_get_clean(); + $this->assertIsString($contents); + $this->assertStringStartsWith('PK', $contents); + + $path = tempnam(sys_get_temp_dir(), 'shopit_excel_'); + $this->assertNotFalse($path); + + try { + file_put_contents($path, $contents); + $assertSpreadsheet($path); + } finally { + @unlink($path); + } + } + + private function tenant(): Tenant + { + return (new Tenant)->forceFill([ + 'codigo' => 'acme', + 'nombre' => 'Acme Eventos', + ]); + } + + private function sale(): Purchase + { + return (new Purchase)->forceFill([ + 'id' => 15, + 'created_at' => now(), + 'nombre_apellido' => 'Cliente Test', + 'quantity' => 2, + 'status' => Purchase::STATUS_PAID, + 'total' => '25000.00', + 'tickets_count' => 2, + ]); + } +}