webmoney/backend/app/Services/RecurringService.php
marco 54cccdd095 refactor: migração para desenvolvimento direto no servidor
- Removido README.md padrão do Laravel (backend)
- Removidos scripts de deploy (não mais necessários)
- Atualizado copilot-instructions.md para novo fluxo
- Adicionada documentação de auditoria do servidor
- Sincronizado código de produção com repositório

Novo workflow:
- Trabalhamos diretamente em /root/webmoney (symlink para /var/www/webmoney)
- Mudanças PHP são instantâneas
- Mudanças React requerem 'npm run build'
- Commit após validação funcional
2025-12-19 11:45:32 +01:00

394 lines
14 KiB
PHP
Executable File

<?php
namespace App\Services;
use App\Models\RecurringTemplate;
use App\Models\RecurringInstance;
use App\Models\Transaction;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class RecurringService
{
/**
* Horizonte padrão de geração (em meses)
*/
private const DEFAULT_HORIZON_MONTHS = 12;
/**
* Cria um template de recorrência a partir de uma transação existente
*/
public function createFromTransaction(
Transaction $transaction,
string $frequency,
array $options = []
): RecurringTemplate {
$template = RecurringTemplate::create([
'user_id' => $transaction->user_id,
'source_transaction_id' => $transaction->id,
'name' => $options['name'] ?? $transaction->description,
'description' => $options['description'] ?? null,
'frequency' => $frequency,
'frequency_interval' => $options['frequency_interval'] ?? 1,
'day_of_month' => $options['day_of_month'] ?? $transaction->planned_date->day,
'day_of_week' => $options['day_of_week'] ?? $transaction->planned_date->dayOfWeek,
'start_date' => $options['start_date'] ?? $transaction->planned_date,
'end_date' => $options['end_date'] ?? null,
'max_occurrences' => $options['max_occurrences'] ?? null,
'account_id' => $transaction->account_id,
'category_id' => $transaction->category_id,
'cost_center_id' => $transaction->cost_center_id,
'type' => $transaction->type,
'planned_amount' => $transaction->planned_amount,
'transaction_description' => $transaction->description,
'notes' => $transaction->notes,
'is_active' => true,
]);
// Gerar instâncias iniciais
$this->generateInstances($template);
return $template->fresh(['instances', 'account', 'category']);
}
/**
* Cria um template de recorrência manualmente
*/
public function createTemplate(int $userId, array $data): RecurringTemplate
{
$template = RecurringTemplate::create([
'user_id' => $userId,
'source_transaction_id' => $data['source_transaction_id'] ?? null,
'name' => $data['name'],
'description' => $data['description'] ?? null,
'frequency' => $data['frequency'],
'frequency_interval' => $data['frequency_interval'] ?? 1,
'day_of_month' => $data['day_of_month'] ?? null,
'day_of_week' => $data['day_of_week'] ?? null,
'start_date' => $data['start_date'],
'end_date' => $data['end_date'] ?? null,
'max_occurrences' => $data['max_occurrences'] ?? null,
'account_id' => $data['account_id'],
'category_id' => $data['category_id'] ?? null,
'cost_center_id' => $data['cost_center_id'] ?? null,
'type' => $data['type'],
'planned_amount' => $data['planned_amount'],
'transaction_description' => $data['transaction_description'],
'notes' => $data['notes'] ?? null,
'is_active' => true,
]);
// Gerar instâncias iniciais
$this->generateInstances($template);
return $template->fresh(['instances', 'account', 'category']);
}
/**
* Gera instâncias para um template até o horizonte definido
*/
public function generateInstances(RecurringTemplate $template, ?int $horizonMonths = null): int
{
if (!$template->canGenerateMore()) {
return 0;
}
$horizonMonths = $horizonMonths ?? self::DEFAULT_HORIZON_MONTHS;
$horizonDate = now()->addMonths($horizonMonths)->endOfMonth();
// Determinar data inicial para geração
$startDate = $template->last_generated_date
? $this->calculateNextDate($template, $template->last_generated_date)
: Carbon::parse($template->start_date);
$generated = 0;
$currentDate = $startDate;
$occurrenceNumber = $template->occurrences_generated;
while ($currentDate->lte($horizonDate)) {
// Verificar limites
if ($template->max_occurrences !== null && $occurrenceNumber >= $template->max_occurrences) {
break;
}
if ($template->end_date !== null && $currentDate->gt($template->end_date)) {
break;
}
// Verificar se já existe instância para esta data
$exists = RecurringInstance::where('recurring_template_id', $template->id)
->where('due_date', $currentDate->toDateString())
->exists();
if (!$exists) {
$occurrenceNumber++;
RecurringInstance::create([
'user_id' => $template->user_id,
'recurring_template_id' => $template->id,
'occurrence_number' => $occurrenceNumber,
'due_date' => $currentDate,
'planned_amount' => $template->planned_amount,
'status' => RecurringInstance::STATUS_PENDING,
]);
$generated++;
}
// Próxima data
$currentDate = $this->calculateNextDate($template, $currentDate);
}
// Atualizar template
if ($generated > 0) {
$template->update([
'last_generated_date' => RecurringInstance::where('recurring_template_id', $template->id)
->max('due_date'),
'occurrences_generated' => $occurrenceNumber,
]);
}
return $generated;
}
/**
* Calcula a próxima data de vencimento baseada na frequência
* IMPORTANTE: Ajusta dias para meses curtos (ex: 31 → 28 em fevereiro)
*/
public function calculateNextDate(RecurringTemplate $template, Carbon $fromDate): Carbon
{
$interval = $template->frequency_interval ?? 1;
$nextDate = $fromDate->copy();
switch ($template->frequency) {
case 'daily':
$nextDate->addDays($interval);
break;
case 'weekly':
$nextDate->addWeeks($interval);
// Se tem dia da semana definido, ajustar
if ($template->day_of_week !== null) {
$nextDate->next($template->day_of_week);
}
break;
case 'biweekly':
$nextDate->addWeeks(2 * $interval);
break;
case 'monthly':
case 'bimonthly':
case 'quarterly':
case 'semiannual':
case 'annual':
$months = match($template->frequency) {
'monthly' => 1,
'bimonthly' => 2,
'quarterly' => 3,
'semiannual' => 6,
'annual' => 12,
} * $interval;
$nextDate = $this->addMonthsWithDayAdjustment(
$fromDate,
$months,
$template->day_of_month
);
break;
}
return $nextDate;
}
/**
* Adiciona meses à data mantendo o dia do mês correto
* Se o dia não existe no mês destino, usa o último dia disponível
*
* Exemplo: 31/Jan + 1 mês = 28/Fev (ou 29 em bissexto)
* 30/Jan + 1 mês = 28/Fev
* 29/Jan + 1 mês = 28/Fev (ou 29 em bissexto)
*/
private function addMonthsWithDayAdjustment(Carbon $date, int $months, ?int $preferredDay = null): Carbon
{
$targetDay = $preferredDay ?? $date->day;
// Avançar os meses
$newDate = $date->copy()->addMonths($months);
// Obter o último dia do mês destino
$lastDayOfMonth = $newDate->copy()->endOfMonth()->day;
// Se o dia preferido é maior que o último dia do mês, usar o último dia
$actualDay = min($targetDay, $lastDayOfMonth);
// Definir o dia correto
$newDate->day($actualDay);
return $newDate;
}
/**
* Concilia uma instância com uma transação existente
*/
public function reconcileWithTransaction(
RecurringInstance $instance,
Transaction $transaction,
?string $notes = null
): RecurringInstance {
return DB::transaction(function () use ($instance, $transaction, $notes) {
$instance->update([
'status' => RecurringInstance::STATUS_PAID,
'transaction_id' => $transaction->id,
'paid_at' => $transaction->effective_date ?? $transaction->planned_date,
'paid_amount' => $transaction->amount ?? $transaction->planned_amount,
'paid_notes' => $notes,
]);
// Atualizar transação com link reverso
$transaction->update([
'recurring_instance_id' => $instance->id,
]);
// Gerar próximas instâncias se necessário
$this->generateInstances($instance->template);
return $instance->fresh(['template', 'transaction']);
});
}
/**
* Marca como pago criando uma nova transação
*/
public function markAsPaid(
RecurringInstance $instance,
array $transactionData = []
): RecurringInstance {
return DB::transaction(function () use ($instance, $transactionData) {
$template = $instance->template;
// Criar transação
$transaction = Transaction::create([
'user_id' => $template->user_id,
'account_id' => $template->account_id,
'category_id' => $template->category_id,
'cost_center_id' => $template->cost_center_id,
'type' => $template->type,
'planned_amount' => $transactionData['amount'] ?? $instance->planned_amount,
'amount' => $transactionData['amount'] ?? $instance->planned_amount,
'planned_date' => $instance->due_date,
'effective_date' => $transactionData['effective_date'] ?? now(),
'description' => $transactionData['description'] ?? $template->transaction_description,
'notes' => $transactionData['notes'] ?? $template->notes,
'status' => 'completed',
'recurring_instance_id' => $instance->id,
]);
// Atualizar instância
$instance->update([
'status' => RecurringInstance::STATUS_PAID,
'transaction_id' => $transaction->id,
'paid_at' => $transaction->effective_date,
'paid_amount' => $transaction->amount,
'paid_notes' => $transactionData['notes'] ?? null,
]);
// Gerar próximas instâncias se necessário
$this->generateInstances($template);
return $instance->fresh(['template', 'transaction']);
});
}
/**
* Pula uma instância
*/
public function skipInstance(RecurringInstance $instance, ?string $reason = null): RecurringInstance
{
$instance->update([
'status' => RecurringInstance::STATUS_SKIPPED,
'paid_notes' => $reason,
]);
// Gerar próximas instâncias se necessário
$this->generateInstances($instance->template);
return $instance->fresh();
}
/**
* Cancela uma instância
*/
public function cancelInstance(RecurringInstance $instance, ?string $reason = null): RecurringInstance
{
$instance->update([
'status' => RecurringInstance::STATUS_CANCELLED,
'paid_notes' => $reason,
]);
return $instance->fresh();
}
/**
* Pausa um template (para de gerar novas instâncias)
*/
public function pauseTemplate(RecurringTemplate $template): RecurringTemplate
{
$template->update(['is_active' => false]);
return $template->fresh();
}
/**
* Reativa um template e gera instâncias faltantes
*/
public function resumeTemplate(RecurringTemplate $template): RecurringTemplate
{
$template->update(['is_active' => true]);
$this->generateInstances($template);
return $template->fresh(['instances']);
}
/**
* Regenera instâncias pendentes para todos os templates ativos de um usuário
* Útil para rodar em um job diário
*/
public function regenerateAllForUser(int $userId): int
{
$templates = RecurringTemplate::where('user_id', $userId)
->where('is_active', true)
->get();
$totalGenerated = 0;
foreach ($templates as $template) {
$totalGenerated += $this->generateInstances($template);
}
return $totalGenerated;
}
/**
* Busca transações candidatas para conciliar com uma instância
*/
public function findCandidateTransactions(RecurringInstance $instance, int $daysTolerance = 7): \Illuminate\Support\Collection
{
$template = $instance->template;
return Transaction::where('user_id', $template->user_id)
->where('account_id', $template->account_id)
->where('type', $template->type)
->whereNull('recurring_instance_id') // Não está vinculada a outra recorrência
->where('status', 'completed')
->whereBetween('effective_date', [
$instance->due_date->copy()->subDays($daysTolerance),
$instance->due_date->copy()->addDays($daysTolerance),
])
->whereBetween('amount', [
$instance->planned_amount * 0.9, // 10% de tolerância
$instance->planned_amount * 1.1,
])
->orderByRaw('ABS(DATEDIFF(effective_date, ?))', [$instance->due_date])
->limit(10)
->get();
}
}