- 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
394 lines
14 KiB
PHP
Executable File
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();
|
|
}
|
|
}
|