diff --git a/app/Events/InstallmentPaidEvent.php b/app/Events/InstallmentPaidEvent.php new file mode 100644 index 00000000..a049434f --- /dev/null +++ b/app/Events/InstallmentPaidEvent.php @@ -0,0 +1,13 @@ +find($event->installmentId); + $project = $installment?->budget?->project; + $agent = $project?->agent; + + if (! $agent) { + Log::warning('installment.email.agent_missing', ['installment_id' => $event->installmentId]); + + return; + } + + $email = collect([$agent->latestSnapshot?->email, $agent->director_email]) + ->map(fn ($value) => trim((string) $value)) + ->first(fn ($value) => filter_var($value, FILTER_VALIDATE_EMAIL)) ?? ''; + + $log = AgentEmailLog::firstOrCreate( + ['deduplication_key' => 'installment_paid:'.$installment->id], + [ + 'agent_id' => $agent->id, + 'project_id' => $project->id, + 'related_type' => $installment->getMorphClass(), + 'related_id' => $installment->id, + 'recipient_email' => $email, + 'recipient_name' => $agent->name, + 'mail_class' => InstallmentPaidMail::class, + 'event_type' => 'installment_paid', + 'subject' => InstallmentPaidMail::SUBJECT, + 'status' => 'queued', + ], + ); + + $failure = DB::transaction(function () use ($log, $installment, $project): ?Throwable { + $log = AgentEmailLog::lockForUpdate()->findOrFail($log->id); + if ($log->status === 'sent') { + return null; + } + + if ($log->recipient_email === '') { + $log->update(['status' => 'failed', 'error_message' => 'Agente sem e-mail válido no snapshot ou no cadastro.']); + + return null; + } + + $log->update(['status' => 'queued', 'error_message' => null]); + try { + Mail::to($log->recipient_email, $log->recipient_name)->send(new InstallmentPaidMail( + recipientName: $log->recipient_name ?? '', + processNumber: $installment->process_number ?? $project->opening?->opening_nup ?? 'Não informado', + projectTitle: $project->title_project ?? '', + installmentNumber: $installment->installment_number, + paymentDate: $installment->payment_date?->format('d/m/Y') ?? 'Não informada', + paymentAmount: number_format((float) $installment->payment_amount, 2, ',', '.'), + )); + } catch (Throwable $e) { + $log->update(['status' => 'failed', 'error_message' => $e->getMessage()]); + + // Commit the attempt history before rethrowing for the queue retry. + return $e; + } + + $log->update(['status' => 'sent', 'sent_at' => now(), 'error_message' => null]); + + return null; + }); + + if ($failure) { + throw ExternalServiceException::unavailable('Envio de e-mail de pagamento', $failure); + } + } +} diff --git a/app/Mail/InstallmentPaidMail.php b/app/Mail/InstallmentPaidMail.php new file mode 100644 index 00000000..3ac499ed --- /dev/null +++ b/app/Mail/InstallmentPaidMail.php @@ -0,0 +1,31 @@ + 'datetime']; + + public static function isAuditingEnabled(): bool + { + return (bool) config('audit.enabled', true); + } + + public function agent(): BelongsTo + { + return $this->belongsTo(Agent::class)->withTrashed(); + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class)->withTrashed(); + } + + public function related(): MorphTo + { + return $this->morphTo()->withTrashed(); + } +} diff --git a/app/Services/InstallmentImportService.php b/app/Services/InstallmentImportService.php index 27b27298..bc1b526b 100644 --- a/app/Services/InstallmentImportService.php +++ b/app/Services/InstallmentImportService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Events\InstallmentPaidEvent; use App\Exceptions\Domain\BusinessRuleException; use App\Models\Budget; use App\Models\Installment; @@ -510,6 +511,10 @@ private function updateInstallment( $newStatus = $this->getInstallmentStatus($installment); + if ($previousStatus !== self::STATUS_PAID_REGULAR && $newStatus === self::STATUS_PAID_REGULAR) { + InstallmentPaidEvent::dispatch($installment->id); + } + $summary['updated']++; $summary['installments'][] = $installment->installment_number; diff --git a/database/migrations/2026_09_22_000001_create_agent_email_logs_table.php b/database/migrations/2026_09_22_000001_create_agent_email_logs_table.php new file mode 100644 index 00000000..6d992176 --- /dev/null +++ b/database/migrations/2026_09_22_000001_create_agent_email_logs_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('agent_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('project_id')->nullable()->constrained()->nullOnDelete(); + $table->nullableMorphs('related'); + $table->string('recipient_email'); + $table->string('recipient_name')->nullable(); + $table->string('mail_class'); + $table->string('event_type')->index(); + $table->string('subject'); + $table->string('status')->default('queued')->index(); + $table->text('error_message')->nullable(); + $table->timestamp('sent_at')->nullable(); + $table->string('deduplication_key')->nullable()->unique(); + $table->timestamps(); + $table->index('agent_id'); + $table->index('project_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('agent_email_logs'); + } +}; diff --git a/docs/installment-paid-email.md b/docs/installment-paid-email.md new file mode 100644 index 00000000..6698a882 --- /dev/null +++ b/docs/installment-paid-email.md @@ -0,0 +1,37 @@ +# Notificação de pagamento ao proponente + +Ao importar o relatório de pagamentos, a transição para pago e regular (valores +previsto, empenhado, liquidado e pago iguais) dispara `InstallmentPaidEvent` após o +commit. O listener `SendInstallmentPaidEmail` usa a fila `default`, já atendida pelo +worker geral. Configure `QUEUE_CONNECTION=database` (ou `redis`) e o transporte +`MAIL_*`; não use `sync` em produção para esse fluxo. + +O destinatário é o e-mail válido de `Agent.latestSnapshot`, com fallback para +`Agent.director_email`. Sem destinatário válido, o envio é registrado como `failed` +e não é tentado. O histórico fica em `agent_email_logs`, com agente, projeto, +parcela (relacionamento polimórfico), destinatário, assunto, status e erro. +As mudanças de `status`, `error_message` e `sent_at` são preservadas em `audits`, +inclusive nas tentativas executadas pelo worker com `AUDITING_CONSOLE=false`, +respeitando `AUDITING_ENABLED`. Uma falha é persistida antes de sinalizar a nova +tentativa à fila. A exclusão física de um agente preserva os registros de e-mail, +definindo `agent_id` como nulo. +`recipient_email` fica vazio quando nenhum endereço válido está disponível. + +Há uma chave única por notificação de parcela e bloqueio do registro durante o +envio para evitar duplicidade em reimportações e processamento concorrente. +Falhas de transporte são registradas e permitem até três tentativas automáticas, +com intervalo de 60 segundos. Como SMTP e banco não compartilham transação, uma +interrupção após o servidor aceitar a mensagem e antes de registrar `sent` pode +resultar em reenvio. + +Para validar localmente, configure o SMTP de teste, execute as migrations e mantenha +o worker ativo. Importe pela lista de editais um relatório que torne uma parcela +paga e regular; confira a caixa de entrada no Roundcube (porta 8025) e o registro +`sent`, com `sent_at` preenchido. Reimporte o mesmo relatório para conferir que não +há outro envio. + +```bash +docker compose exec app php artisan migrate +docker compose up -d queue greenmail roundcube +docker compose exec app php artisan test tests/Feature/InstallmentPaidEmailTest.php tests/Feature/InstallmentImportServiceTest.php tests/Unit/InstallmentPaidMailTest.php +``` diff --git a/resources/views/mail/installment-paid.blade.php b/resources/views/mail/installment-paid.blade.php new file mode 100644 index 00000000..755acd8d --- /dev/null +++ b/resources/views/mail/installment-paid.blade.php @@ -0,0 +1,22 @@ +@extends('mail.layouts.default') + +@section('title', 'Pagamento confirmado — e-fomento') + +@section('content') +

Olá, {{ $recipientName }}.

+

Pagamento de parcela confirmado

+

O pagamento da parcela do seu projeto foi confirmado.

+ + + + +
+

Número do processo: {{ $processNumber }}

+

Projeto: {{ $projectTitle }}

+

Parcela: {{ $installmentNumber }}

+

Data de pagamento: {{ $paymentDate }}

+

Valor pago: R$ {{ $paymentAmount }}

+
+

Precisa de ajuda? Acesse o suporte.

+

Até mais!

+@endsection diff --git a/resources/views/mail/layouts/default.blade.php b/resources/views/mail/layouts/default.blade.php new file mode 100644 index 00000000..edac2a48 --- /dev/null +++ b/resources/views/mail/layouts/default.blade.php @@ -0,0 +1,48 @@ + + + + + + @yield('title') + + + + + + +
+ + + + + + + + + + + + + +
+ + + + + + + + +
+ e-fomento +

Secretaria da Cultura do Ceará

+
+ @yield('content') +
+

e-fomento

+ {{ config('app.url') }} + Ceará — Governo do Estado — Secretaria da Cultura +
+
+ + diff --git a/resources/views/mail/login-code.blade.php b/resources/views/mail/login-code.blade.php index f857697b..edb66862 100644 --- a/resources/views/mail/login-code.blade.php +++ b/resources/views/mail/login-code.blade.php @@ -1,59 +1,18 @@ - - - - - - Seu código de acesso ao e-fomento - - - - - - -
- - - - - - - - - - - - - -
- - - - - - - - -
- e-fomento -

Secretaria da Cultura do Ceará

-
-

Olá!

-

Confirme seu acesso ao e-fomento

-

Para concluir seu acesso, digite o código abaixo na tela de verificação:

- - - - -
{{ $code }}
-

O código expira em {{ $ttlMinutes }} minutos e só pode ser usado uma vez.

-

Não compartilhe este código. Se você não tentou entrar, altere sua senha ou entre em contato com o suporte.

-

Precisa de ajuda? Acesse o suporte.

-

Até mais!

-
-

e-fomento

- {{ config('app.url') }} - Ceará — Governo do Estado — Secretaria da Cultura -
-
- - +@extends('mail.layouts.default') + +@section('title', 'Seu código de acesso ao e-fomento') + +@section('content') +

Olá!

+

Confirme seu acesso ao e-fomento

+

Para concluir seu acesso, digite o código abaixo na tela de verificação:

+ + + + +
{{ $code }}
+

O código expira em {{ $ttlMinutes }} minutos e só pode ser usado uma vez.

+

Não compartilhe este código. Se você não tentou entrar, altere sua senha ou entre em contato com o suporte.

+

Precisa de ajuda? Acesse o suporte.

+

Até mais!

+@endsection diff --git a/tests/Feature/InstallmentImportServiceTest.php b/tests/Feature/InstallmentImportServiceTest.php index ae522405..637ee405 100644 --- a/tests/Feature/InstallmentImportServiceTest.php +++ b/tests/Feature/InstallmentImportServiceTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use App\Events\InstallmentPaidEvent; use App\Exceptions\Domain\BusinessRuleException; use App\Models\Budget; use App\Models\Installment; @@ -10,6 +11,7 @@ use App\Services\InstallmentImportService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Str; use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Writer\Xlsx; @@ -708,6 +710,29 @@ public function test_it_throws_when_selected_project_has_no_valid_opening_nup(): ); } + public function test_paid_transition_dispatches_once_on_reimport(): void + { + Event::fake([InstallmentPaidEvent::class]); + $project = Project::factory()->create(); + $project->opening->update(['opening_nup' => '23000.000001/2024-10']); + $budget = Budget::factory()->create(['project_id' => $project->id]); + $installment = Installment::factory()->create([ + 'budget_id' => $budget->id, 'installment_number' => 1, 'amount' => 1000.50, + 'committed_amount' => null, 'settlement_amount' => null, 'payment_amount' => null, + ]); + $irregularFile = $this->makeSpreadsheet([$this->paymentRow()]); + $this->importSpreadsheet($irregularFile, [$project->id]); + Event::assertNotDispatched(InstallmentPaidEvent::class); + $file = $this->makeSpreadsheet([$this->paymentRow([ + 'Empenhado' => '1.000,50', 'Liquidado' => '1.000,50', 'Pago' => '1.000,50', + ])]); + $this->importSpreadsheet($file, [$project->id]); + $this->importSpreadsheet($file, [$project->id]); + Event::assertDispatchedTimes(InstallmentPaidEvent::class, 1); + Event::assertDispatched(InstallmentPaidEvent::class, + fn ($event) => $event->installmentId === $installment->id); + } + private function importSpreadsheet( UploadedFile $file, array $selectedProjectIds, diff --git a/tests/Feature/InstallmentPaidEmailTest.php b/tests/Feature/InstallmentPaidEmailTest.php new file mode 100644 index 00000000..a3d80b5d --- /dev/null +++ b/tests/Feature/InstallmentPaidEmailTest.php @@ -0,0 +1,187 @@ +create(['title_project' => 'Projeto Cultural']); + $project->agent->update(['director_email' => null]); + ProfileSnapshot::factory()->create([ + 'object_id' => $project->agent_id, 'object_type' => 'agent', 'email' => $email, + ]); + + return Installment::factory()->create([ + 'budget_id' => Budget::factory()->create(['project_id' => $project->id])->id, + 'installment_number' => 1, 'payment_date' => '2026-09-01', + 'payment_amount' => 1000.50, 'process_number' => '12345/2026', + ]); + } + + public function test_sends_and_audits_only_once_with_relationships_and_content(): void + { + Mail::fake(); + $installment = $this->installment(); + $event = new InstallmentPaidEvent($installment->id); + $listener = app(SendInstallmentPaidEmail::class); + InstallmentPaidEvent::dispatch($installment->id); + $listener->handle($event); + + Mail::assertSent(InstallmentPaidMail::class, 1); + Mail::assertSent(InstallmentPaidMail::class, function ($mail) { + $this->assertTrue($mail->hasTo('agent@example.com')); + $mail->assertSeeInHtml('Projeto Cultural'); + $mail->assertSeeInHtml('12345/2026'); + $mail->assertSeeInHtml('01/09/2026'); + $mail->assertSeeInHtml('R$ 1.000,50'); + $this->assertSame(1, $mail->installmentNumber); + + return true; + }); + $log = AgentEmailLog::sole(); + $this->assertSame('sent', $log->status); + $this->assertNotNull($log->sent_at); + $this->assertTrue($log->related->is($installment)); + $this->assertTrue($log->project->is($installment->budget->project)); + $this->assertTrue($log->agent->is($installment->budget->project->agent)); + } + + public function test_uses_director_email_when_snapshot_email_is_invalid(): void + { + Mail::fake(); + $installment = $this->installment('invalid'); + $installment->budget->project->agent->update(['director_email' => 'director@example.com']); + app(SendInstallmentPaidEmail::class)->handle(new InstallmentPaidEvent($installment->id)); + Mail::assertSent(InstallmentPaidMail::class, fn ($mail) => $mail->hasTo('director@example.com')); + } + + public function test_missing_email_is_audited_without_sending(): void + { + Mail::fake(); + $installment = $this->installment(null); + app(SendInstallmentPaidEmail::class)->handle(new InstallmentPaidEvent($installment->id)); + Mail::assertNothingSent(); + $this->assertSame('failed', AgentEmailLog::sole()->status); + $this->assertNotNull(AgentEmailLog::sole()->error_message); + $this->assertNull(AgentEmailLog::sole()->sent_at); + } + + public function test_repeated_failures_and_success_preserve_history_without_console_auditing(): void + { + config(['audit.console' => false]); + $this->assertTrue(app()->runningInConsole()); + $installment = $this->installment(); + $mailer = Mail::getFacadeRoot(); + Mail::shouldReceive('to')->twice()->andReturnSelf(); + Mail::shouldReceive('send')->twice()->andReturnUsing(function () { + $this->assertSame('queued', AgentEmailLog::sole()->status); + throw new \RuntimeException('SMTP indisponível'); + }); + $listener = app(SendInstallmentPaidEmail::class); + $event = new InstallmentPaidEvent($installment->id); + for ($attempt = 0; $attempt < 2; $attempt++) { + try { + $listener->handle($event); + $this->fail('Expected a mail transport failure'); + } catch (ExternalServiceException $e) { + $this->assertSame('SMTP indisponível', $e->getPrevious()->getMessage()); + } + $this->assertSame('failed', AgentEmailLog::sole()->status); + $this->assertSame('SMTP indisponível', AgentEmailLog::sole()->error_message); + $this->assertNull(AgentEmailLog::sole()->sent_at); + $this->assertCount($attempt + 1, AgentEmailLog::sole()->audits()->get() + ->filter(fn ($audit) => ($audit->new_values['status'] ?? null) === 'failed')); + } + Mail::swap($mailer); + Mail::fake(); + $listener->handle($event); + $log = AgentEmailLog::sole(); + $this->assertSame('sent', $log->status); + $this->assertNull($log->error_message); + $this->assertNotNull($log->sent_at); + $audits = $log->audits()->orderBy('id')->get(); + $this->assertSame(['queued', 'failed', 'queued', 'failed', 'queued', 'sent'], + $audits->map(fn ($audit) => $audit->new_values['status'])->all()); + foreach ([1, 3] as $index) { + $this->assertSame('SMTP indisponível', $audits[$index]->new_values['error_message']); + $this->assertSame('SMTP indisponível', $audits[$index + 1]->old_values['error_message']); + $this->assertNull($audits[$index + 1]->new_values['error_message']); + } + $this->assertNotEmpty($audits->last()->new_values['sent_at']); + $listener->handle($event); + $this->assertSame($audits->count(), $log->audits()->count()); + Mail::assertSent(InstallmentPaidMail::class, 1); + } + + public function test_agent_deletion_preserves_email_log_and_audits(): void + { + $agent = Agent::factory()->create(); + $log = AgentEmailLog::create([ + 'agent_id' => $agent->id, + 'recipient_email' => 'agent@example.com', + 'recipient_name' => $agent->name, + 'mail_class' => InstallmentPaidMail::class, + 'event_type' => 'installment_paid', + 'subject' => InstallmentPaidMail::SUBJECT, + 'status' => 'sent', + 'sent_at' => now(), + ]); + $auditIds = $log->audits()->pluck('id')->all(); + $this->assertNotEmpty($auditIds); + + $agent->delete(); + $this->assertTrue($log->fresh()->agent->is($agent)); + $agent->forceDelete(); + + $log->refresh(); + $this->assertNull($log->agent_id); + $this->assertNull($log->agent); + $this->assertSame('agent@example.com', $log->recipient_email); + $this->assertSame('sent', $log->status); + $this->assertSame($auditIds, $log->audits()->pluck('id')->all()); + } + + public function test_event_queues_listener_only_after_commit_and_not_after_rollback(): void + { + Queue::fake(); + DB::beginTransaction(); + InstallmentPaidEvent::dispatch(123); + Queue::assertNothingPushed(); + DB::rollBack(); + Queue::assertNothingPushed(); + + DB::beginTransaction(); + InstallmentPaidEvent::dispatch(123); + Queue::assertNothingPushed(); + DB::commit(); + Queue::assertPushedOn('default', CallQueuedListener::class, fn ($job) => $job->class === SendInstallmentPaidEmail::class); + } + + public function test_deleted_installment_does_not_send(): void + { + Mail::fake(); + app(SendInstallmentPaidEmail::class)->handle(new InstallmentPaidEvent(99999)); + Mail::assertNothingSent(); + $this->assertDatabaseCount('agent_email_logs', 0); + } +} diff --git a/tests/Unit/InstallmentPaidMailTest.php b/tests/Unit/InstallmentPaidMailTest.php new file mode 100644 index 00000000..303a784d --- /dev/null +++ b/tests/Unit/InstallmentPaidMailTest.php @@ -0,0 +1,22 @@ +alert(1)', 2, '01/09/2026', '1.000,50'); + $this->assertSame(InstallmentPaidMail::SUBJECT, $mail->envelope()->subject); + $mail->assertSeeInHtml('Maria'); + $mail->assertSeeInHtml('123/2026'); + $this->assertStringContainsString('<script>alert(1)</script>', $mail->render()); + $this->assertStringNotContainsString('