324 lines
8.6 KiB
PHP
324 lines
8.6 KiB
PHP
<?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)
|
|
{
|
|
return $query->where('status', 'completed');
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|