mirror of
https://github.com/moonshadowrev/PersonalAccounter.git
synced 2025-12-19 07:19:38 -06:00
1052 lines
39 KiB
PHP
1052 lines
39 KiB
PHP
<?php
|
|
|
|
require_once __DIR__ . '/Controller.php';
|
|
require_once __DIR__ . '/../Models/Expense.php';
|
|
require_once __DIR__ . '/../Models/Category.php';
|
|
require_once __DIR__ . '/../Models/Tag.php';
|
|
require_once __DIR__ . '/../Models/CreditCard.php';
|
|
require_once __DIR__ . '/../Models/BankAccount.php';
|
|
require_once __DIR__ . '/../Models/CryptoWallet.php';
|
|
|
|
class ExpenseController extends Controller {
|
|
|
|
private $expenseModel;
|
|
private $categoryModel;
|
|
private $tagModel;
|
|
private $creditCardModel;
|
|
private $bankAccountModel;
|
|
private $cryptoWalletModel;
|
|
|
|
public function __construct($db) {
|
|
$this->expenseModel = new Expense($db);
|
|
$this->categoryModel = new Category($db);
|
|
$this->tagModel = new Tag($db);
|
|
$this->creditCardModel = new CreditCard($db);
|
|
$this->bankAccountModel = new BankAccount($db);
|
|
$this->cryptoWalletModel = new CryptoWallet($db);
|
|
}
|
|
|
|
private function checkAuthentication() {
|
|
if (session_status() === PHP_SESSION_NONE) {
|
|
session_start();
|
|
}
|
|
|
|
if (!isset($_SESSION['user']['id'])) {
|
|
header('Location: /login');
|
|
exit();
|
|
}
|
|
}
|
|
|
|
public function index() {
|
|
$this->checkAuthentication();
|
|
|
|
// Get filter parameters
|
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
|
$limit = min(100, max(10, (int)($_GET['limit'] ?? 25)));
|
|
$filters = [
|
|
'category_id' => $_GET['category_id'] ?? null,
|
|
'tag_id' => $_GET['tag_id'] ?? null,
|
|
'payment_method' => $_GET['payment_method'] ?? null,
|
|
'payment_id' => $_GET['payment_id'] ?? null,
|
|
'status' => $_GET['status'] ?? null,
|
|
'date_from' => $_GET['date_from'] ?? null,
|
|
'date_to' => $_GET['date_to'] ?? null,
|
|
'amount_min' => $_GET['amount_min'] ?? null,
|
|
'amount_max' => $_GET['amount_max'] ?? null,
|
|
'search' => $_GET['search'] ?? null
|
|
];
|
|
|
|
// Get all expenses for centralized system
|
|
$expenses = $this->expenseModel->getAllExpensesWithFilters($filters, $page, $limit);
|
|
$totalCount = $this->expenseModel->countAllExpensesWithFilters($filters);
|
|
|
|
// Get filter options - all data for centralized system
|
|
$categories = $this->categoryModel->getAllWithUserInfo();
|
|
$tags = $this->tagModel->getAllWithUserInfo();
|
|
$creditCards = $this->creditCardModel->getAllWithUserInfo();
|
|
$bankAccounts = $this->bankAccountModel->getAllWithUserInfo();
|
|
$cryptoWallets = $this->cryptoWalletModel->getAllWithUserInfo();
|
|
|
|
// Get summary statistics for all expenses
|
|
$stats = $this->expenseModel->getAllExpenseStats($filters);
|
|
|
|
// Map stats to what the view expects
|
|
$stats['total_count'] = $stats['total_expenses'];
|
|
$stats['pending_count'] = $stats['pending_expenses'];
|
|
$stats['approved_count'] = $stats['approved_expenses'];
|
|
|
|
$this->view('dashboard/expenses/index', [
|
|
'expenses' => $expenses,
|
|
'categories' => $categories,
|
|
'tags' => $tags,
|
|
'creditCards' => $creditCards,
|
|
'bankAccounts' => $bankAccounts,
|
|
'cryptoWallets' => $cryptoWallets,
|
|
'stats' => $stats,
|
|
'filters' => $filters,
|
|
'pagination' => [
|
|
'current_page' => $page,
|
|
'total_pages' => ceil($totalCount / $limit),
|
|
'total_count' => $totalCount,
|
|
'limit' => $limit
|
|
],
|
|
'load_datatable' => true,
|
|
'datatable_target' => '#expenses-table'
|
|
]);
|
|
}
|
|
|
|
public function create() {
|
|
$this->checkAuthentication();
|
|
|
|
$userId = $_SESSION['user']['id'];
|
|
|
|
// Get all data for centralized system - not user-specific
|
|
$categories = $this->categoryModel->getAllWithUserInfo();
|
|
$tags = $this->tagModel->getAllWithUserInfo();
|
|
$creditCards = $this->creditCardModel->getAllWithUserInfo();
|
|
$bankAccounts = $this->bankAccountModel->getAllWithUserInfo();
|
|
$cryptoWallets = $this->cryptoWalletModel->getAllWithUserInfo();
|
|
|
|
// Check if there's at least one payment method available
|
|
if (empty($creditCards) && empty($bankAccounts) && empty($cryptoWallets)) {
|
|
FlashMessage::warning('You need to add at least one payment method (credit card, bank account, or crypto wallet) before creating an expense.');
|
|
header('Location: /credit-cards/create');
|
|
exit();
|
|
}
|
|
|
|
$this->view('dashboard/expenses/create', [
|
|
'categories' => $categories,
|
|
'tags' => $tags,
|
|
'creditCards' => $creditCards,
|
|
'bankAccounts' => $bankAccounts,
|
|
'cryptoWallets' => $cryptoWallets,
|
|
'currencies' => BankAccount::getSupportedCurrencies(),
|
|
'taxRates' => [
|
|
'0' => '0% (No Tax)',
|
|
'5' => '5%',
|
|
'8' => '8%',
|
|
'10' => '10%',
|
|
'15' => '15%',
|
|
'20' => '20%',
|
|
'25' => '25%'
|
|
]
|
|
]);
|
|
}
|
|
|
|
public function store() {
|
|
$this->checkAuthentication();
|
|
|
|
if (!$this->validateCsrfToken()) {
|
|
FlashMessage::error('Invalid security token. Please try again.');
|
|
header('Location: /expenses/create');
|
|
exit();
|
|
}
|
|
|
|
$userId = $_SESSION['user']['id'];
|
|
|
|
// Validate input
|
|
$title = trim($_POST['title'] ?? '');
|
|
$description = trim($_POST['description'] ?? '');
|
|
$amount = floatval($_POST['amount'] ?? 0);
|
|
$currency = trim($_POST['currency'] ?? 'USD');
|
|
$categoryId = !empty($_POST['category_id']) ? (int)$_POST['category_id'] : null;
|
|
$tagIds = $_POST['tag_ids'] ?? [];
|
|
$paymentMethod = trim($_POST['payment_method'] ?? '');
|
|
$paymentId = !empty($_POST['payment_id']) ? (int)$_POST['payment_id'] : null;
|
|
$expenseDate = $_POST['expense_date'] ?? '';
|
|
$dueDate = $_POST['due_date'] ?? null;
|
|
$taxRate = floatval($_POST['tax_rate'] ?? 0);
|
|
$notes = trim($_POST['notes'] ?? '');
|
|
$status = trim($_POST['status'] ?? 'pending');
|
|
|
|
// Validation
|
|
if (empty($title)) {
|
|
FlashMessage::error('Title is required.');
|
|
header('Location: /expenses/create');
|
|
exit();
|
|
}
|
|
|
|
if ($amount <= 0) {
|
|
FlashMessage::error('Amount must be greater than 0.');
|
|
header('Location: /expenses/create');
|
|
exit();
|
|
}
|
|
|
|
if (empty($paymentMethod)) {
|
|
FlashMessage::error('Payment method is required.');
|
|
header('Location: /expenses/create');
|
|
exit();
|
|
}
|
|
|
|
if (empty($paymentId)) {
|
|
FlashMessage::error('Payment method selection is required.');
|
|
header('Location: /expenses/create');
|
|
exit();
|
|
}
|
|
|
|
if (empty($expenseDate)) {
|
|
FlashMessage::error('Expense date is required.');
|
|
header('Location: /expenses/create');
|
|
exit();
|
|
}
|
|
|
|
// Validate payment method exists (no ownership check for centralized system)
|
|
$paymentValid = false;
|
|
switch ($paymentMethod) {
|
|
case 'credit_card':
|
|
$card = $this->creditCardModel->find($paymentId);
|
|
$paymentValid = ($card !== null);
|
|
break;
|
|
case 'bank_account':
|
|
$account = $this->bankAccountModel->find($paymentId);
|
|
$paymentValid = ($account !== null);
|
|
break;
|
|
case 'crypto_wallet':
|
|
$wallet = $this->cryptoWalletModel->find($paymentId);
|
|
$paymentValid = ($wallet !== null);
|
|
break;
|
|
}
|
|
|
|
if (!$paymentValid) {
|
|
FlashMessage::error('Invalid payment method selected.');
|
|
header('Location: /expenses/create');
|
|
exit();
|
|
}
|
|
|
|
// Validate category if provided (no ownership check for centralized system)
|
|
if ($categoryId) {
|
|
$category = $this->categoryModel->find($categoryId);
|
|
if (!$category) {
|
|
FlashMessage::error('Invalid category selected.');
|
|
header('Location: /expenses/create');
|
|
exit();
|
|
}
|
|
}
|
|
|
|
// Validate tags if provided (no ownership check for centralized system)
|
|
if (!empty($tagIds)) {
|
|
foreach ($tagIds as $tagId) {
|
|
$tag = $this->tagModel->find($tagId);
|
|
if (!$tag) {
|
|
FlashMessage::error('Invalid tag selected.');
|
|
header('Location: /expenses/create');
|
|
exit();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate dates
|
|
if (!strtotime($expenseDate)) {
|
|
FlashMessage::error('Invalid expense date.');
|
|
header('Location: /expenses/create');
|
|
exit();
|
|
}
|
|
|
|
if ($dueDate && !strtotime($dueDate)) {
|
|
FlashMessage::error('Invalid due date.');
|
|
header('Location: /expenses/create');
|
|
exit();
|
|
}
|
|
|
|
// Calculate tax amount
|
|
$taxAmount = ($taxRate > 0) ? ($amount * $taxRate / 100) : 0;
|
|
$totalAmount = $amount + $taxAmount;
|
|
|
|
// Handle file upload
|
|
$attachments = null;
|
|
if (!empty($_FILES['attachment']['name'])) {
|
|
$attachmentPath = $this->handleFileUpload($_FILES['attachment'], $userId);
|
|
if (!$attachmentPath) {
|
|
FlashMessage::error('Failed to upload attachment.');
|
|
header('Location: /expenses/create');
|
|
exit();
|
|
}
|
|
$attachments = json_encode([$attachmentPath]);
|
|
}
|
|
|
|
// Map payment method to correct columns
|
|
$paymentData = [];
|
|
switch ($paymentMethod) {
|
|
case 'credit_card':
|
|
$paymentData['credit_card_id'] = $paymentId;
|
|
$paymentData['bank_account_id'] = null;
|
|
$paymentData['crypto_wallet_id'] = null;
|
|
break;
|
|
case 'bank_account':
|
|
$paymentData['credit_card_id'] = null;
|
|
$paymentData['bank_account_id'] = $paymentId;
|
|
$paymentData['crypto_wallet_id'] = null;
|
|
break;
|
|
case 'crypto_wallet':
|
|
$paymentData['credit_card_id'] = null;
|
|
$paymentData['bank_account_id'] = null;
|
|
$paymentData['crypto_wallet_id'] = $paymentId;
|
|
break;
|
|
}
|
|
|
|
$data = [
|
|
'user_id' => $userId,
|
|
'title' => $title,
|
|
'description' => $description,
|
|
'amount' => $amount,
|
|
'currency' => $currency,
|
|
'category_id' => $categoryId,
|
|
'payment_method_type' => $paymentMethod,
|
|
'credit_card_id' => $paymentData['credit_card_id'],
|
|
'bank_account_id' => $paymentData['bank_account_id'],
|
|
'crypto_wallet_id' => $paymentData['crypto_wallet_id'],
|
|
'expense_date' => $expenseDate,
|
|
'tax_rate' => $taxRate,
|
|
'tax_amount' => $taxAmount,
|
|
'notes' => $notes,
|
|
'status' => $status,
|
|
'attachments' => $attachments
|
|
];
|
|
|
|
try {
|
|
$expenseId = $this->expenseModel->create($data);
|
|
|
|
if ($expenseId) {
|
|
// Add tags if provided
|
|
if (!empty($tagIds)) {
|
|
$this->expenseModel->addTags($expenseId, $tagIds);
|
|
}
|
|
|
|
// Generate transaction if expense is approved
|
|
if ($status === 'approved') {
|
|
$this->expenseModel->generateTransaction($expenseId);
|
|
}
|
|
|
|
AppLogger::info('Expense created', [
|
|
'user_id' => $userId,
|
|
'expense_id' => $expenseId,
|
|
'title' => $title,
|
|
'amount' => $totalAmount
|
|
]);
|
|
|
|
FlashMessage::success('Expense created successfully!');
|
|
header('Location: /expenses');
|
|
} else {
|
|
FlashMessage::error('Failed to create expense. Please try again.');
|
|
header('Location: /expenses/create');
|
|
}
|
|
} catch (Exception $e) {
|
|
AppLogger::error('Failed to create expense', [
|
|
'user_id' => $userId,
|
|
'error' => $e->getMessage()
|
|
]);
|
|
FlashMessage::error('Failed to create expense. Please try again.');
|
|
header('Location: /expenses/create');
|
|
}
|
|
|
|
exit();
|
|
}
|
|
|
|
public function edit($id) {
|
|
$this->checkAuthentication();
|
|
|
|
$userId = $_SESSION['user']['id'];
|
|
$expense = $this->expenseModel->findWithRelations($id);
|
|
|
|
if (!$expense) {
|
|
FlashMessage::error('Expense not found.');
|
|
header('Location: /expenses');
|
|
exit();
|
|
}
|
|
|
|
// Get all data for centralized system - not user-specific
|
|
$categories = $this->categoryModel->getAllWithUserInfo();
|
|
$tags = $this->tagModel->getAllWithUserInfo();
|
|
$creditCards = $this->creditCardModel->getAllWithUserInfo();
|
|
$bankAccounts = $this->bankAccountModel->getAllWithUserInfo();
|
|
$cryptoWallets = $this->cryptoWalletModel->getAllWithUserInfo();
|
|
|
|
// Check if there's at least one payment method available
|
|
if (empty($creditCards) && empty($bankAccounts) && empty($cryptoWallets)) {
|
|
FlashMessage::warning('You need to have at least one payment method (credit card, bank account, or crypto wallet) to edit expenses.');
|
|
header('Location: /credit-cards/create');
|
|
exit();
|
|
}
|
|
|
|
// Get expense tags
|
|
$expenseTags = $this->expenseModel->getExpenseTags($id);
|
|
$selectedTagIds = array_column($expenseTags, 'id');
|
|
|
|
$this->view('dashboard/expenses/edit', [
|
|
'expense' => $expense,
|
|
'categories' => $categories,
|
|
'tags' => $tags,
|
|
'creditCards' => $creditCards,
|
|
'bankAccounts' => $bankAccounts,
|
|
'cryptoWallets' => $cryptoWallets,
|
|
'selectedTagIds' => $selectedTagIds,
|
|
'currencies' => BankAccount::getSupportedCurrencies(),
|
|
'taxRates' => [
|
|
'0' => '0% (No Tax)',
|
|
'5' => '5%',
|
|
'8' => '8%',
|
|
'10' => '10%',
|
|
'15' => '15%',
|
|
'20' => '20%',
|
|
'25' => '25%'
|
|
]
|
|
]);
|
|
}
|
|
|
|
public function update($id) {
|
|
$this->checkAuthentication();
|
|
|
|
if (!$this->validateCsrfToken()) {
|
|
FlashMessage::error('Invalid security token. Please try again.');
|
|
header('Location: /expenses/' . $id . '/edit');
|
|
exit();
|
|
}
|
|
|
|
$userId = $_SESSION['user']['id'];
|
|
$expense = $this->expenseModel->find($id);
|
|
|
|
if (!$expense) {
|
|
FlashMessage::error('Expense not found.');
|
|
header('Location: /expenses');
|
|
exit();
|
|
}
|
|
|
|
// Similar validation as store method
|
|
$title = trim($_POST['title'] ?? '');
|
|
$description = trim($_POST['description'] ?? '');
|
|
$amount = floatval($_POST['amount'] ?? 0);
|
|
$currency = trim($_POST['currency'] ?? 'USD');
|
|
$categoryId = !empty($_POST['category_id']) ? (int)$_POST['category_id'] : null;
|
|
$tagIds = $_POST['tag_ids'] ?? [];
|
|
$expenseDate = $_POST['expense_date'] ?? '';
|
|
$taxRate = floatval($_POST['tax_rate'] ?? 0);
|
|
$notes = trim($_POST['notes'] ?? '');
|
|
$status = trim($_POST['status'] ?? 'pending');
|
|
|
|
// Validation (same as store method)
|
|
if (empty($title)) {
|
|
FlashMessage::error('Title is required.');
|
|
header('Location: /expenses/' . $id . '/edit');
|
|
exit();
|
|
}
|
|
|
|
if ($amount <= 0) {
|
|
FlashMessage::error('Amount must be greater than 0.');
|
|
header('Location: /expenses/' . $id . '/edit');
|
|
exit();
|
|
}
|
|
|
|
if (empty($expenseDate)) {
|
|
FlashMessage::error('Expense date is required.');
|
|
header('Location: /expenses/' . $id . '/edit');
|
|
exit();
|
|
}
|
|
|
|
// Calculate tax amount
|
|
$taxAmount = ($taxRate > 0) ? ($amount * $taxRate / 100) : 0;
|
|
$totalAmount = $amount + $taxAmount;
|
|
|
|
// Handle file upload
|
|
$attachments = $expense['attachments'];
|
|
if (!empty($_FILES['attachment']['name'])) {
|
|
$newAttachmentPath = $this->handleFileUpload($_FILES['attachment'], $userId);
|
|
if ($newAttachmentPath) {
|
|
// Delete old attachments if they exist
|
|
if ($attachments) {
|
|
$oldAttachments = json_decode($attachments, true);
|
|
if (is_array($oldAttachments)) {
|
|
foreach ($oldAttachments as $oldPath) {
|
|
if (file_exists($oldPath)) {
|
|
unlink($oldPath);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
$attachments = json_encode([$newAttachmentPath]);
|
|
}
|
|
}
|
|
|
|
// Use existing payment method data (don't change payment method in edit)
|
|
$data = [
|
|
'title' => $title,
|
|
'description' => $description,
|
|
'amount' => $amount,
|
|
'currency' => $currency,
|
|
'category_id' => $categoryId,
|
|
'expense_date' => $expenseDate,
|
|
'tax_rate' => $taxRate,
|
|
'tax_amount' => $taxAmount,
|
|
'notes' => $notes,
|
|
'status' => $status,
|
|
'attachments' => $attachments
|
|
];
|
|
|
|
try {
|
|
$result = $this->expenseModel->update($id, $data);
|
|
|
|
if ($result) {
|
|
// Update tags
|
|
$this->expenseModel->removeTags($id);
|
|
if (!empty($tagIds)) {
|
|
$this->expenseModel->addTags($id, $tagIds);
|
|
}
|
|
|
|
// Handle transaction generation based on status change
|
|
if ($status === 'approved' && $expense['status'] !== 'approved') {
|
|
$this->expenseModel->generateTransaction($id);
|
|
} elseif ($status !== 'approved' && $expense['status'] === 'approved') {
|
|
$this->expenseModel->removeTransaction($id);
|
|
}
|
|
|
|
AppLogger::info('Expense updated', [
|
|
'user_id' => $userId,
|
|
'expense_id' => $id,
|
|
'title' => $title
|
|
]);
|
|
|
|
FlashMessage::success('Expense updated successfully!');
|
|
} else {
|
|
FlashMessage::error('No changes were made.');
|
|
}
|
|
} catch (Exception $e) {
|
|
AppLogger::error('Failed to update expense', [
|
|
'user_id' => $userId,
|
|
'expense_id' => $id,
|
|
'error' => $e->getMessage()
|
|
]);
|
|
FlashMessage::error('Failed to update expense. Please try again.');
|
|
}
|
|
|
|
header('Location: /expenses');
|
|
exit();
|
|
}
|
|
|
|
public function delete($id) {
|
|
$this->checkAuthentication();
|
|
|
|
if (!$this->validateCsrfToken()) {
|
|
FlashMessage::error('Invalid security token. Please try again.');
|
|
header('Location: /expenses');
|
|
exit();
|
|
}
|
|
|
|
$userId = $_SESSION['user']['id'];
|
|
$expense = $this->expenseModel->find($id);
|
|
|
|
if (!$expense) {
|
|
FlashMessage::error('Expense not found.');
|
|
header('Location: /expenses');
|
|
exit();
|
|
}
|
|
|
|
try {
|
|
$result = $this->expenseModel->delete($id);
|
|
|
|
if ($result) {
|
|
// Delete attachments if they exist
|
|
if ($expense['attachments']) {
|
|
$attachments = json_decode($expense['attachments'], true);
|
|
if (is_array($attachments)) {
|
|
foreach ($attachments as $attachmentPath) {
|
|
if (file_exists($attachmentPath)) {
|
|
unlink($attachmentPath);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
AppLogger::info('Expense deleted', [
|
|
'user_id' => $userId,
|
|
'expense_id' => $id,
|
|
'title' => $expense['title']
|
|
]);
|
|
|
|
FlashMessage::success('Expense deleted successfully!');
|
|
} else {
|
|
FlashMessage::error('Failed to delete expense. Please try again.');
|
|
}
|
|
} catch (Exception $e) {
|
|
AppLogger::error('Failed to delete expense', [
|
|
'user_id' => $userId,
|
|
'expense_id' => $id,
|
|
'error' => $e->getMessage()
|
|
]);
|
|
FlashMessage::error('Failed to delete expense. Please try again.');
|
|
}
|
|
|
|
header('Location: /expenses');
|
|
exit();
|
|
}
|
|
|
|
public function show($id) {
|
|
$this->checkAuthentication();
|
|
|
|
// Get expense with creator information
|
|
$expense = $this->expenseModel->findWithRelations($id);
|
|
|
|
if (!$expense) {
|
|
FlashMessage::error('Expense not found.');
|
|
header('Location: /expenses');
|
|
exit();
|
|
}
|
|
|
|
// Get related data
|
|
$category = $expense['category_id'] ? $this->categoryModel->find($expense['category_id']) : null;
|
|
$tags = $this->expenseModel->getExpenseTags($id);
|
|
$transaction = $this->expenseModel->getTransaction($id);
|
|
|
|
$this->view('dashboard/expenses/view', [
|
|
'expense' => $expense,
|
|
'category' => $category,
|
|
'tags' => $tags,
|
|
'transaction' => $transaction
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Import expenses from Excel file
|
|
*/
|
|
public function import() {
|
|
$this->checkAuthentication();
|
|
|
|
$userId = $_SESSION['user']['id'];
|
|
|
|
$categories = $this->categoryModel->getAllWithUserInfo();
|
|
$tags = $this->tagModel->getAllWithUserInfo();
|
|
$creditCards = $this->creditCardModel->getAllWithUserInfo();
|
|
$bankAccounts = $this->bankAccountModel->getAllWithUserInfo();
|
|
$cryptoWallets = $this->cryptoWalletModel->getAllWithUserInfo();
|
|
|
|
// Check if there's at least one payment method available
|
|
if (empty($creditCards) && empty($bankAccounts) && empty($cryptoWallets)) {
|
|
FlashMessage::warning('You need to have at least one payment method (credit card, bank account, or crypto wallet) to import expenses.');
|
|
header('Location: /credit-cards/create');
|
|
exit();
|
|
}
|
|
|
|
$this->view('dashboard/expenses/import', [
|
|
'categories' => $categories,
|
|
'tags' => $tags,
|
|
'creditCards' => $creditCards,
|
|
'bankAccounts' => $bankAccounts,
|
|
'cryptoWallets' => $cryptoWallets
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Process Excel import
|
|
*/
|
|
public function processImport() {
|
|
$this->checkAuthentication();
|
|
|
|
if (!$this->validateCsrfToken()) {
|
|
FlashMessage::error('Invalid security token. Please try again.');
|
|
header('Location: /expenses/import');
|
|
exit();
|
|
}
|
|
|
|
$userId = $_SESSION['user']['id'];
|
|
|
|
// Validate file upload
|
|
if (empty($_FILES['excel_file']['tmp_name'])) {
|
|
FlashMessage::error('Please select an Excel file to import.');
|
|
header('Location: /expenses/import');
|
|
exit();
|
|
}
|
|
|
|
$file = $_FILES['excel_file'];
|
|
$allowedTypes = [
|
|
'application/vnd.ms-excel',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'text/csv',
|
|
'application/csv'
|
|
];
|
|
|
|
if (!in_array($file['type'], $allowedTypes)) {
|
|
FlashMessage::error('Please upload a valid Excel file (.xls, .xlsx) or CSV file (.csv).');
|
|
header('Location: /expenses/import');
|
|
exit();
|
|
}
|
|
|
|
// Check PHP upload limits instead of hardcoded limit
|
|
$maxUpload = (int)(ini_get('upload_max_filesize'));
|
|
$maxPost = (int)(ini_get('post_max_size'));
|
|
$maxFilesize = min($maxUpload, $maxPost) * 1024 * 1024;
|
|
|
|
if ($file['size'] > $maxFilesize) {
|
|
$sizeMB = round($maxFilesize / (1024 * 1024));
|
|
FlashMessage::error("File size exceeds PHP limit of {$sizeMB}MB. Please check your PHP upload settings.");
|
|
header('Location: /expenses/import');
|
|
exit();
|
|
}
|
|
|
|
try {
|
|
$result = $this->expenseModel->importFromExcel($file['tmp_name'], $userId, $_POST);
|
|
|
|
if ($result['success']) {
|
|
AppLogger::info('Expenses imported from Excel', [
|
|
'user_id' => $userId,
|
|
'imported_count' => $result['imported_count'],
|
|
'skipped_count' => $result['skipped_count'],
|
|
'file_name' => $file['name'],
|
|
'file_size' => $file['size']
|
|
]);
|
|
|
|
$message = "Successfully imported {$result['imported_count']} expenses.";
|
|
if ($result['skipped_count'] > 0) {
|
|
$message .= " {$result['skipped_count']} rows were skipped.";
|
|
}
|
|
if (!empty($result['errors'])) {
|
|
$message .= " Some issues were found: " . implode(', ', array_slice($result['errors'], 0, 3));
|
|
if (count($result['errors']) > 3) {
|
|
$message .= " and " . (count($result['errors']) - 3) . " more.";
|
|
}
|
|
}
|
|
|
|
FlashMessage::success($message);
|
|
header('Location: /expenses');
|
|
} else {
|
|
AppLogger::warning('Excel import failed', [
|
|
'user_id' => $userId,
|
|
'error' => $result['error'],
|
|
'file_name' => $file['name'],
|
|
'file_size' => $file['size']
|
|
]);
|
|
|
|
FlashMessage::error('Import failed: ' . ($result['error'] ?? 'Unknown error'));
|
|
header('Location: /expenses/import');
|
|
}
|
|
} catch (Exception $e) {
|
|
AppLogger::error('Failed to import expenses', [
|
|
'user_id' => $userId,
|
|
'error' => $e->getMessage(),
|
|
'file_name' => $file['name'] ?? 'unknown',
|
|
'file_size' => $file['size'] ?? 0,
|
|
'trace' => $e->getTraceAsString()
|
|
]);
|
|
FlashMessage::error('Failed to import expenses: ' . $e->getMessage());
|
|
header('Location: /expenses/import');
|
|
}
|
|
|
|
exit();
|
|
}
|
|
|
|
/**
|
|
* Export expenses to Excel
|
|
*/
|
|
public function export() {
|
|
$this->checkAuthentication();
|
|
|
|
// Get filter parameters
|
|
$filters = [
|
|
'category_id' => $_GET['category_id'] ?? null,
|
|
'tag_id' => $_GET['tag_id'] ?? null,
|
|
'payment_method' => $_GET['payment_method'] ?? null,
|
|
'payment_id' => $_GET['payment_id'] ?? null,
|
|
'status' => $_GET['status'] ?? null,
|
|
'date_from' => $_GET['date_from'] ?? null,
|
|
'date_to' => $_GET['date_to'] ?? null,
|
|
'amount_min' => $_GET['amount_min'] ?? null,
|
|
'amount_max' => $_GET['amount_max'] ?? null,
|
|
'search' => $_GET['search'] ?? null
|
|
];
|
|
|
|
try {
|
|
// Export all expenses for centralized system
|
|
$filePath = $this->expenseModel->exportToExcel(null, $filters);
|
|
|
|
if ($filePath && file_exists($filePath)) {
|
|
$fileName = 'expenses_' . date('Y-m-d_H-i-s') . '.xlsx';
|
|
|
|
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
|
header('Content-Disposition: attachment; filename="' . $fileName . '"');
|
|
header('Content-Length: ' . filesize($filePath));
|
|
|
|
readfile($filePath);
|
|
|
|
// Clean up temporary file
|
|
unlink($filePath);
|
|
exit();
|
|
} else {
|
|
FlashMessage::error('Failed to generate export file.');
|
|
header('Location: /expenses');
|
|
exit();
|
|
}
|
|
} catch (Exception $e) {
|
|
AppLogger::error('Failed to export expenses', [
|
|
'user_id' => $_SESSION['user']['id'],
|
|
'error' => $e->getMessage()
|
|
]);
|
|
FlashMessage::error('Failed to export expenses. Please try again.');
|
|
header('Location: /expenses');
|
|
exit();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Approve expense (change status to approved and generate transaction)
|
|
*/
|
|
public function approve($id) {
|
|
$this->checkAuthentication();
|
|
|
|
if (!$this->validateCsrfToken()) {
|
|
FlashMessage::error('Invalid security token. Please try again.');
|
|
header('Location: /expenses');
|
|
exit();
|
|
}
|
|
|
|
$userId = $_SESSION['user']['id'];
|
|
$expense = $this->expenseModel->find($id);
|
|
|
|
if (!$expense) {
|
|
FlashMessage::error('Expense not found.');
|
|
header('Location: /expenses');
|
|
exit();
|
|
}
|
|
|
|
try {
|
|
$result = $this->expenseModel->approve($id);
|
|
|
|
if ($result) {
|
|
FlashMessage::success('Expense approved successfully!');
|
|
} else {
|
|
FlashMessage::error('Failed to approve expense.');
|
|
}
|
|
} catch (Exception $e) {
|
|
AppLogger::error('Failed to approve expense', [
|
|
'user_id' => $userId,
|
|
'expense_id' => $id,
|
|
'error' => $e->getMessage()
|
|
]);
|
|
FlashMessage::error('Failed to approve expense. Please try again.');
|
|
}
|
|
|
|
header('Location: /expenses');
|
|
exit();
|
|
}
|
|
|
|
/**
|
|
* Reject expense (change status to rejected and remove transaction)
|
|
*/
|
|
public function reject($id) {
|
|
$this->checkAuthentication();
|
|
|
|
if (!$this->validateCsrfToken()) {
|
|
FlashMessage::error('Invalid security token. Please try again.');
|
|
header('Location: /expenses');
|
|
exit();
|
|
}
|
|
|
|
$userId = $_SESSION['user']['id'];
|
|
$expense = $this->expenseModel->find($id);
|
|
|
|
if (!$expense) {
|
|
FlashMessage::error('Expense not found.');
|
|
header('Location: /expenses');
|
|
exit();
|
|
}
|
|
|
|
try {
|
|
$result = $this->expenseModel->reject($id);
|
|
|
|
if ($result) {
|
|
FlashMessage::success('Expense rejected successfully!');
|
|
} else {
|
|
FlashMessage::error('Failed to reject expense.');
|
|
}
|
|
} catch (Exception $e) {
|
|
AppLogger::error('Failed to reject expense', [
|
|
'user_id' => $userId,
|
|
'expense_id' => $id,
|
|
'error' => $e->getMessage()
|
|
]);
|
|
FlashMessage::error('Failed to reject expense. Please try again.');
|
|
}
|
|
|
|
header('Location: /expenses');
|
|
exit();
|
|
}
|
|
|
|
/**
|
|
* Get expense analytics (AJAX endpoint)
|
|
*/
|
|
public function analytics() {
|
|
$this->checkAuthentication();
|
|
|
|
$period = $_GET['period'] ?? 'month';
|
|
|
|
// Get analytics for all expenses in centralized system
|
|
$analytics = $this->expenseModel->getAnalytics(null, $period);
|
|
|
|
header('Content-Type: application/json');
|
|
echo json_encode([
|
|
'success' => true,
|
|
'analytics' => $analytics
|
|
]);
|
|
exit();
|
|
}
|
|
|
|
/**
|
|
* Download attachment
|
|
*/
|
|
public function downloadAttachment($id) {
|
|
$this->checkAuthentication();
|
|
|
|
$expense = $this->expenseModel->find($id);
|
|
|
|
if (!$expense) {
|
|
FlashMessage::error('Expense not found.');
|
|
header('Location: /expenses');
|
|
exit();
|
|
}
|
|
|
|
if (!$expense['attachments']) {
|
|
FlashMessage::error('Attachment not found.');
|
|
header('Location: /expenses');
|
|
exit();
|
|
}
|
|
|
|
$attachments = json_decode($expense['attachments'], true);
|
|
if (!is_array($attachments) || empty($attachments)) {
|
|
FlashMessage::error('Attachment not found.');
|
|
header('Location: /expenses');
|
|
exit();
|
|
}
|
|
|
|
$attachmentPath = $attachments[0]; // Get first attachment
|
|
if (!file_exists($attachmentPath)) {
|
|
FlashMessage::error('Attachment file not found.');
|
|
header('Location: /expenses');
|
|
exit();
|
|
}
|
|
|
|
$fileName = basename($attachmentPath);
|
|
$mimeType = mime_content_type($attachmentPath);
|
|
|
|
header('Content-Type: ' . $mimeType);
|
|
header('Content-Disposition: attachment; filename="' . $fileName . '"');
|
|
header('Content-Length: ' . filesize($attachmentPath));
|
|
|
|
readfile($attachmentPath);
|
|
exit();
|
|
}
|
|
|
|
/**
|
|
* Download Excel template for import
|
|
*/
|
|
public function downloadTemplate() {
|
|
$this->checkAuthentication();
|
|
|
|
try {
|
|
// Create new spreadsheet
|
|
$spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
|
|
$sheet = $spreadsheet->getActiveSheet();
|
|
|
|
// Set headers
|
|
$sheet->setCellValue('A1', 'Title');
|
|
$sheet->setCellValue('B1', 'Amount');
|
|
$sheet->setCellValue('C1', 'Category');
|
|
$sheet->setCellValue('D1', 'Date');
|
|
$sheet->setCellValue('E1', 'Description');
|
|
$sheet->setCellValue('F1', 'Currency');
|
|
$sheet->setCellValue('G1', 'Tax Rate');
|
|
$sheet->setCellValue('H1', 'Notes');
|
|
$sheet->setCellValue('I1', 'Tags');
|
|
|
|
// Style headers
|
|
$headerStyle = [
|
|
'font' => ['bold' => true],
|
|
'fill' => [
|
|
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
|
|
'startColor' => ['rgb' => 'E9ECEF']
|
|
]
|
|
];
|
|
$sheet->getStyle('A1:I1')->applyFromArray($headerStyle);
|
|
|
|
// Add sample data
|
|
$sampleData = [
|
|
['Office Supplies', '150.00', 'Office', '2024-01-15', 'Pens, paper, and other office supplies', 'USD', '8.25', 'Purchased from Office Depot', 'business,office'],
|
|
['Business Lunch', '75.50', 'Meals', '2024-01-16', 'Client meeting lunch', 'USD', '0', 'Meeting with potential client', 'client,food'],
|
|
['Software License', '299.99', 'Software', '2024-01-17', 'Annual subscription to project management tool', 'USD', '0', 'Required for team collaboration', 'software,tools'],
|
|
['Fuel', '45.20', 'Transportation', '2024-01-18', 'Gas for business trip', 'USD', '0', 'Trip to client site', 'travel,fuel'],
|
|
['Hotel Stay', '180.00', 'Travel', '2024-01-19', 'One night hotel stay', 'USD', '12.50', 'Business conference attendance', 'travel,conference']
|
|
];
|
|
|
|
// Add sample data to sheet
|
|
$row = 2;
|
|
foreach ($sampleData as $data) {
|
|
$sheet->setCellValue('A' . $row, $data[0]);
|
|
$sheet->setCellValue('B' . $row, $data[1]);
|
|
$sheet->setCellValue('C' . $row, $data[2]);
|
|
$sheet->setCellValue('D' . $row, $data[3]);
|
|
$sheet->setCellValue('E' . $row, $data[4]);
|
|
$sheet->setCellValue('F' . $row, $data[5]);
|
|
$sheet->setCellValue('G' . $row, $data[6]);
|
|
$sheet->setCellValue('H' . $row, $data[7]);
|
|
$sheet->setCellValue('I' . $row, $data[8]);
|
|
$row++;
|
|
}
|
|
|
|
// Auto-size columns
|
|
foreach (range('A', 'I') as $column) {
|
|
$sheet->getColumnDimension($column)->setAutoSize(true);
|
|
}
|
|
|
|
// Create writer
|
|
$writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet);
|
|
|
|
// Set headers for download
|
|
$fileName = 'expense_import_template_' . date('Y-m-d') . '.xlsx';
|
|
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
|
header('Content-Disposition: attachment; filename="' . $fileName . '"');
|
|
header('Cache-Control: max-age=0');
|
|
|
|
// Output file
|
|
$writer->save('php://output');
|
|
|
|
} catch (Exception $e) {
|
|
AppLogger::error('Failed to generate expense template', [
|
|
'user_id' => $_SESSION['user']['id'],
|
|
'error' => $e->getMessage()
|
|
]);
|
|
FlashMessage::error('Failed to generate template. Please try again.');
|
|
header('Location: /expenses/import');
|
|
}
|
|
|
|
exit();
|
|
}
|
|
|
|
/**
|
|
* Handle file upload
|
|
*/
|
|
private function handleFileUpload($file, $userId) {
|
|
$allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf', 'text/plain'];
|
|
|
|
if (!in_array($file['type'], $allowedTypes)) {
|
|
return false;
|
|
}
|
|
|
|
// Check PHP upload limits instead of hardcoded limit
|
|
$maxUpload = (int)(ini_get('upload_max_filesize'));
|
|
$maxPost = (int)(ini_get('post_max_size'));
|
|
$maxFilesize = min($maxUpload, $maxPost) * 1024 * 1024;
|
|
|
|
if ($file['size'] > $maxFilesize) {
|
|
return false;
|
|
}
|
|
|
|
$uploadDir = 'uploads/expenses/' . $userId . '/';
|
|
if (!is_dir($uploadDir)) {
|
|
mkdir($uploadDir, 0755, true);
|
|
}
|
|
|
|
$fileName = uniqid() . '_' . $file['name'];
|
|
$filePath = $uploadDir . $fileName;
|
|
|
|
if (move_uploaded_file($file['tmp_name'], $filePath)) {
|
|
return $filePath;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|