$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(); } }