webmoney/backend/app/Models/Transaction.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

325 lines
8.7 KiB
PHP
Executable File

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Transaction extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'user_id',
'account_id',
'category_id',
'cost_center_id',
'amount',
'planned_amount',
'type',
'description',
'original_description',
'notes',
'effective_date',
'planned_date',
'status',
'reference',
'import_hash',
'import_log_id',
'is_recurring',
'recurring_parent_id',
'recurring_instance_id',
'transfer_pair_id',
'parent_transaction_id',
'is_split_child',
'is_split_parent',
'duplicate_ignored_with',
'is_transfer',
'transfer_linked_id',
'is_refund_pair',
'refund_linked_id',
];
protected $casts = [
'amount' => 'decimal:2',
'planned_amount' => 'decimal:2',
'effective_date' => 'date',
'planned_date' => 'date',
'is_recurring' => 'boolean',
'is_split_child' => 'boolean',
'is_split_parent' => 'boolean',
'is_transfer' => 'boolean',
'is_refund_pair' => 'boolean',
];
// =========================================================================
// RELACIONAMENTOS
// =========================================================================
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function account(): BelongsTo
{
return $this->belongsTo(Account::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function costCenter(): BelongsTo
{
return $this->belongsTo(CostCenter::class);
}
public function recurringParent(): BelongsTo
{
return $this->belongsTo(Transaction::class, 'recurring_parent_id');
}
public function recurringChildren(): HasMany
{
return $this->hasMany(Transaction::class, 'recurring_parent_id');
}
/**
* Instância de recorrência vinculada
*/
public function recurringInstance(): BelongsTo
{
return $this->belongsTo(RecurringInstance::class, 'recurring_instance_id');
}
public function importLog(): BelongsTo
{
return $this->belongsTo(ImportLog::class);
}
/**
* Transação par de transferência (débito ↔ crédito)
*/
public function transferPair(): BelongsTo
{
return $this->belongsTo(Transaction::class, 'transfer_pair_id');
}
/**
* Transação pai (quando esta é uma divisão)
*/
public function parentTransaction(): BelongsTo
{
return $this->belongsTo(Transaction::class, 'parent_transaction_id');
}
/**
* Transações filhas (divisões desta transação)
*/
public function splitChildren(): HasMany
{
return $this->hasMany(Transaction::class, 'parent_transaction_id');
}
// =========================================================================
// SCOPES
// =========================================================================
public function scopeOfUser($query, $userId)
{
return $query->where('user_id', $userId);
}
public function scopePending($query)
{
return $query->where('status', 'pending');
}
public function scopeCompleted($query)
{
// Incluir 'completed' e 'effective' como transações efetivadas
return $query->whereIn('status', ['completed', 'effective']);
}
public function scopeCancelled($query)
{
return $query->where('status', 'cancelled');
}
public function scopeCredits($query)
{
return $query->where('type', 'credit');
}
public function scopeDebits($query)
{
return $query->where('type', 'debit');
}
public function scopeInPeriod($query, $startDate, $endDate, $dateField = 'planned_date')
{
return $query->whereBetween($dateField, [$startDate, $endDate]);
}
public function scopeOfAccount($query, $accountId)
{
return $query->where('account_id', $accountId);
}
public function scopeOfCategory($query, $categoryId)
{
return $query->where('category_id', $categoryId);
}
public function scopeOfCostCenter($query, $costCenterId)
{
return $query->where('cost_center_id', $costCenterId);
}
// =========================================================================
// ATRIBUTOS COMPUTADOS
// =========================================================================
/**
* Retorna o valor final (efetivo se existir, senão planejado)
*/
public function getFinalAmountAttribute(): float
{
return $this->amount ?? $this->planned_amount;
}
/**
* Retorna a data final (efetiva se existir, senão planejada)
*/
public function getFinalDateAttribute()
{
return $this->effective_date ?? $this->planned_date;
}
/**
* Verifica se a transação está atrasada (pendente e data planejada passou)
*/
public function getIsOverdueAttribute(): bool
{
if ($this->status !== 'pending') {
return false;
}
return $this->planned_date < now()->startOfDay();
}
/**
* Retorna o valor com sinal (positivo para crédito, negativo para débito)
*/
public function getSignedAmountAttribute(): float
{
$amount = $this->final_amount;
return $this->type === 'credit' ? $amount : -$amount;
}
// =========================================================================
// MÉTODOS
// =========================================================================
/**
* Marca a transação como concluída
*/
public function markAsCompleted(?float $amount = null, ?string $effectiveDate = null): self
{
$this->status = 'completed';
$this->amount = $amount ?? $this->planned_amount;
$this->effective_date = $effectiveDate ?? now()->toDateString();
$this->save();
return $this;
}
/**
* Marca a transação como cancelada
*/
public function markAsCancelled(): self
{
$this->status = 'cancelled';
$this->save();
return $this;
}
/**
* Reverte para pendente
*/
public function markAsPending(): self
{
$this->status = 'pending';
$this->amount = null;
$this->effective_date = null;
$this->save();
return $this;
}
/**
* Gera hash único para evitar duplicidade na importação
* Baseado em: data + valor + descrição original + saldo (se disponível no extrato)
*
* O saldo é usado APENAS para diferenciar transações idênticas no hash,
* mas NÃO é armazenado na BD para não interferir no cálculo dinâmico de saldo.
*/
public static function generateImportHash(
string $date,
float $amount,
?string $originalDescription,
?float $balance = null
): string {
// Normaliza os valores para garantir consistência
$normalizedDate = date('Y-m-d', strtotime($date));
$normalizedAmount = number_format($amount, 2, '.', '');
$normalizedDescription = trim(strtolower($originalDescription ?? ''));
// Prepara os componentes do hash
$components = [
$normalizedDate,
$normalizedAmount,
$normalizedDescription,
];
// Se o saldo foi fornecido no extrato, usa para diferenciar transações idênticas
if ($balance !== null) {
$components[] = number_format($balance, 2, '.', '');
}
// Concatena os valores e gera hash SHA-256
return hash('sha256', implode('|', $components));
}
/**
* Verifica se já existe transação com este hash para o usuário
*/
public static function existsByHash(int $userId, string $hash): bool
{
return self::where('user_id', $userId)
->where('import_hash', $hash)
->exists();
}
/**
* Scope para buscar por hash de importação
*/
public function scopeByImportHash($query, string $hash)
{
return $query->where('import_hash', $hash);
}
/**
* Verifica se a transação foi importada
*/
public function getIsImportedAttribute(): bool
{
return !empty($this->import_hash);
}
}