Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/Events/InstallmentPaidEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace App\Events;

use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;

class InstallmentPaidEvent implements ShouldDispatchAfterCommit
{
use Dispatchable;

public function __construct(public readonly int $installmentId) {}
}
95 changes: 95 additions & 0 deletions app/Listeners/SendInstallmentPaidEmail.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

namespace App\Listeners;

use App\Events\InstallmentPaidEvent;
use App\Exceptions\Integration\ExternalServiceException;
use App\Mail\InstallmentPaidMail;
use App\Models\AgentEmailLog;
use App\Models\Installment;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;

class SendInstallmentPaidEmail implements ShouldQueue
{
public string $queue = 'default';

public int $tries = 3;

public int $backoff = 60;

public function handle(InstallmentPaidEvent $event): void
{
$installment = Installment::with('budget.project.agent.latestSnapshot', 'budget.project.opening')
->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);
}
}
}
31 changes: 31 additions & 0 deletions app/Mail/InstallmentPaidMail.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace App\Mail;

use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;

class InstallmentPaidMail extends Mailable
{
public const SUBJECT = 'Pagamento de parcela confirmado — e-fomento';

public function __construct(
public readonly string $recipientName,
public readonly string $processNumber,
public readonly string $projectTitle,
public readonly int $installmentNumber,
public readonly string $paymentDate,
public readonly string $paymentAmount,
) {}

public function envelope(): Envelope
{
return new Envelope(subject: self::SUBJECT);
}

public function content(): Content
{
return new Content(view: 'mail.installment-paid');
}
}
58 changes: 58 additions & 0 deletions app/Models/AgentEmailLog.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use OwenIt\Auditing\Auditable as AuditableTrait;
use OwenIt\Auditing\Contracts\Auditable;

class AgentEmailLog extends Model implements Auditable
{
use AuditableTrait;

protected $auditInclude = [
'status',
'error_message',
'sent_at',
];

protected $fillable = [
'agent_id',
'project_id',
'related_type',
'related_id',
'recipient_email',
'recipient_name',
'mail_class',
'event_type',
'subject',
'status',
'error_message',
'sent_at',
'deduplication_key',
];

protected $casts = ['sent_at' => '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();
}
}
5 changes: 5 additions & 0 deletions app/Services/InstallmentImportService.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Services;

use App\Events\InstallmentPaidEvent;
use App\Exceptions\Domain\BusinessRuleException;
use App\Models\Budget;
use App\Models\Installment;
Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::create('agent_email_logs', function (Blueprint $table) {
$table->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');
}
};
37 changes: 37 additions & 0 deletions docs/installment-paid-email.md
Original file line number Diff line number Diff line change
@@ -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
```
22 changes: 22 additions & 0 deletions resources/views/mail/installment-paid.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
@extends('mail.layouts.default')

@section('title', 'Pagamento confirmado — e-fomento')

@section('content')
<p style="margin: 0 0 18px;">Olá, {{ $recipientName }}.</p>
<h1 style="margin: 0 0 12px; font-size: 22px; font-weight: bold; line-height: 1.4;">Pagamento de parcela confirmado</h1>
<p style="margin: 0 0 24px;">O pagamento da parcela do seu projeto foi confirmado.</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 0 0 24px; background-color: #f0f8f3; border: 1px solid #c9e3d3; border-radius: 8px;">
<tr>
<td style="padding: 20px 24px; font-size: 15px; line-height: 1.7; word-break: break-word;">
<p style="margin: 0 0 12px;"><strong>Número do processo:</strong> {{ $processNumber }}</p>
<p style="margin: 0 0 12px;"><strong>Projeto:</strong> {{ $projectTitle }}</p>
<p style="margin: 0 0 12px;"><strong>Parcela:</strong> {{ $installmentNumber }}</p>
<p style="margin: 0 0 12px;"><strong>Data de pagamento:</strong> {{ $paymentDate }}</p>
<p style="margin: 0; color: #008344; font-size: 18px;"><strong>Valor pago:</strong> R$ {{ $paymentAmount }}</p>
</td>
</tr>
</table>
<p style="margin: 0 0 12px;"><a href="https://suporte.secult.ce.gov.br/" style="color: #008344; text-decoration: underline;">Precisa de ajuda? Acesse o suporte.</a></p>
<p style="margin: 0;">Até mais!</p>
@endsection
48 changes: 48 additions & 0 deletions resources/views/mail/layouts/default.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>@yield('title')</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f5f5f5; font-family: Arial, Helvetica, sans-serif; color: #2d353f;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f5f5f5;">
<tr>
<td align="center" style="padding: 24px 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 800px; background-color: #ffffff; border-radius: 14px; overflow: hidden;">
<tr>
<td>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" aria-hidden="true">
<tr>
<td width="25%" height="8" bgcolor="#28b099" style="font-size: 0; line-height: 0;">&nbsp;</td>
<td width="25%" height="8" bgcolor="#c4d833" style="font-size: 0; line-height: 0;">&nbsp;</td>
<td width="25%" height="8" bgcolor="#ffcc00" style="font-size: 0; line-height: 0;">&nbsp;</td>
<td width="25%" height="8" bgcolor="#ef4b0b" style="font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center" style="padding: 42px 24px 48px;">
<a href="{{ config('app.url') }}" style="color: #2d353f; font-size: 48px; font-weight: bold; letter-spacing: -2px; line-height: 1.2; text-decoration: none;">e-fomento</a>
<p style="margin: 12px 0 0; color: #69736e; font-size: 14px; line-height: 1.5;">Secretaria da Cultura do Ceará</p>
</td>
</tr>
<tr>
<td style="padding: 0 28px 36px; font-size: 15px; line-height: 1.7;">
@yield('content')
</td>
</tr>
<tr>
<td align="center" bgcolor="#008344" style="padding: 22px 24px; border-radius: 0 0 14px 14px; color: #ffffff; font-size: 14px; line-height: 1.6;">
<p style="margin: 0 0 4px; font-size: 16px; font-weight: bold;">e-fomento</p>
<a href="{{ config('app.url') }}" style="color: #ffffff; text-decoration: none; word-break: break-word;">{{ config('app.url') }}</a>
<img src="{{ $message->embed(public_path('images/logos/ceara-white.png')) }}" width="170" alt="Ceará — Governo do Estado — Secretaria da Cultura" style="display: block; width: 170px; max-width: 100%; height: auto; margin: 14px auto 0; border: 0;">
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
Loading
Loading