CREATE TABLE `finance` ( `id` int NOT NULL AUTO_INCREMENT, `type` enum('income','expense') NOT NULL, `amount` decimal(12,2) NOT NULL, `category_id` int NOT NULL, `account_id` int NOT NULL, `date` date NOT NULL, `note` varchar(255) DEFAULT '', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_date` (`date`), KEY `idx_type` (`type`), KEY `idx_category` (`category_id`), KEY `idx_account` (`account_id`), CONSTRAINT `fk_finance_account` FOREIGN KEY (`account_id`) REFERENCES `finance_accounts` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE ) ENGINE=InnoDB AUTO_INCREMENT=4595 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci CREATE TABLE `finance_accounts` ( `id` int NOT NULL AUTO_INCREMENT, `name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, `color` varchar(7) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '#6ec6ff', `sort_order` int NOT NULL DEFAULT '0', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uniq_name` (`name`) ) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci CREATE TABLE `finance_adjustments` ( `id` int NOT NULL AUTO_INCREMENT, `account_id` int DEFAULT NULL, `amount` decimal(12,2) NOT NULL, `date` date NOT NULL, `note` varchar(255) DEFAULT '', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_acc_adj` (`account_id`), CONSTRAINT `fk_adj_account` FOREIGN KEY (`account_id`) REFERENCES `finance_accounts` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci CREATE TABLE `finance_categories` ( `id` int NOT NULL AUTO_INCREMENT, `type` enum('income','expense') NOT NULL, `name` varchar(50) NOT NULL, `color` varchar(7) NOT NULL DEFAULT '#78ff87', `sort_order` int NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `uniq_type_name` (`type`,`name`) ) ENGINE=InnoDB AUTO_INCREMENT=59 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci CREATE TABLE `finance_goals` ( `id` int NOT NULL AUTO_INCREMENT, `name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, `target_amount` decimal(12,2) NOT NULL, `current_amount` decimal(12,2) NOT NULL DEFAULT '0.00', `color` varchar(7) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '#78ff87', `deadline` date DEFAULT NULL, `note` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '', `sort_order` int NOT NULL DEFAULT '0', `is_archived` tinyint(1) NOT NULL DEFAULT '0', `created_at` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]; try { $pdo = new PDO($dsn, $user, $pass, $options); } catch (\PDOException $e) { throw new \PDOException($e->getMessage(), (int)$e->getCode()); } $error = ''; $success = ''; /** * Парсер SQL-значений (для обратной совместимости, если используется старый импорт) */ if (!function_exists('parseSqlValues')) { function parseSqlValues($raw) { $vals = []; $len = strlen($raw); $i = 0; while ($i < $len) { while ($i < $len && ($raw[$i] === ' ' || $raw[$i] === ',' || $raw[$i] === "\t")) $i++; if ($i >= $len) break; if ($raw[$i] === "'") { $i++; $val = ''; while ($i < $len) { $ch = $raw[$i]; if ($ch === '\\' && $i + 1 < $len) { $next = $raw[$i + 1]; if ($next === "'" || $next === '\\') { $val .= $next; $i += 2; continue; } $val .= $ch; $i++; continue; } if ($ch === "'") { if ($i + 1 < $len && $raw[$i + 1] === "'") { $val .= "'"; $i += 2; continue; } $i++; break; } $val .= $ch; $i++; } $vals[] = $val; } elseif ($raw[$i] === 'N' && $i + 3 < $len && strtoupper(substr($raw, $i, 4)) === 'NULL') { $vals[] = null; $i += 4; } else { $start = $i; while ($i < $len && $raw[$i] !== ',' && $raw[$i] !== ')') $i++; $token = trim(substr($raw, $start, $i - $start)); if (strtoupper($token) === 'NULL') $vals[] = null; else $vals[] = $token; } } return $vals; } } // ============================================================ // Создание таблицы целей при необходимости // ============================================================ try { $pdo->exec("CREATE TABLE IF NOT EXISTS finance_goals ( id int NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL, target_amount decimal(12,2) NOT NULL, current_amount decimal(12,2) NOT NULL DEFAULT 0, color varchar(7) NOT NULL DEFAULT '#78ff87', deadline date DEFAULT NULL, note varchar(255) DEFAULT '', sort_order int NOT NULL DEFAULT 0, is_archived tinyint(1) NOT NULL DEFAULT 0, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); // Автомиграция: добавить недостающие колонки, если таблица была создана раньше $cols = []; $stmt = $pdo->query("SHOW COLUMNS FROM finance_goals"); while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) $cols[$r['Field']] = true; if (!isset($cols['note'])) { $pdo->exec("ALTER TABLE finance_goals ADD COLUMN note varchar(255) DEFAULT '' AFTER deadline"); } if (!isset($cols['sort_order'])) { $pdo->exec("ALTER TABLE finance_goals ADD COLUMN sort_order int NOT NULL DEFAULT 0 AFTER note"); } if (!isset($cols['is_archived'])) { $pdo->exec("ALTER TABLE finance_goals ADD COLUMN is_archived tinyint(1) NOT NULL DEFAULT 0 AFTER sort_order"); } } catch (PDOException $e) {} // ============================================================ // POST: обработка действий // ============================================================ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $action = $_POST['action'] ?? ''; // ------------------------------------------------------------ // ЭКСПОРТ В CSV (Excel-совместимый, разделитель ;) // ------------------------------------------------------------ if ($action === 'export_csv') { $what = $_POST['what'] ?? 'all'; $filename = 'finance_export_' . date('Y-m-d_H-i-s') . '.csv'; header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Pragma: no-cache'); header('Expires: 0'); $out = fopen('php://output', 'w'); fwrite($out, "\xEF\xBB\xBF"); $dec = ','; // Хелпер: fputcsv с явным escape (требование PHP 8.4+) $csv = function($handle, $fields) { fputcsv($handle, $fields, ';', '"', ''); }; if ($what === 'categories' || $what === 'all') { $csv($out, ['=== CATEGORIES ===']); $csv($out, ['id', 'type', 'name', 'color', 'sort_order']); $stmt = $pdo->query("SELECT id, type, name, color, sort_order FROM finance_categories ORDER BY type, sort_order, id"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $csv($out, [$row['id'], $row['type'], $row['name'], $row['color'], $row['sort_order']]); } $csv($out, []); } if ($what === 'finance' || $what === 'all') { $csv($out, ['=== FINANCE ===']); $csv($out, ['id', 'type', 'amount', 'category_id', 'category_name', 'account_id', 'account_name', 'date', 'note']); $stmt = $pdo->query(" SELECT f.id, f.type, f.amount, f.category_id, c.name AS category_name, f.account_id, a.name AS account_name, f.date, f.note FROM finance f LEFT JOIN finance_categories c ON c.id = f.category_id LEFT JOIN finance_accounts a ON a.id = f.account_id ORDER BY f.date DESC, f.id DESC "); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $csv($out, [ $row['id'], $row['type'], number_format((float)$row['amount'], 2, $dec, ''), $row['category_id'], $row['category_name'], $row['account_id'], $row['account_name'], $row['date'], $row['note'] ]); } $csv($out, []); } if ($what === 'adjustments' || $what === 'all') { $csv($out, ['=== ADJUSTMENTS ===']); $csv($out, ['id', 'account_id', 'amount', 'date', 'note']); $stmt = $pdo->query("SELECT id, account_id, amount, date, note FROM finance_adjustments ORDER BY date DESC, id DESC"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $csv($out, [$row['id'], $row['account_id'], number_format((float)$row['amount'], 2, $dec, ''), $row['date'], $row['note']]); } $csv($out, []); } if ($what === 'goals' || $what === 'all') { $csv($out, ['=== GOALS ===']); $csv($out, ['id', 'name', 'target_amount', 'current_amount', 'color', 'deadline', 'note', 'sort_order', 'is_archived']); try { $stmt = $pdo->query("SELECT id, name, target_amount, current_amount, color, deadline, note, sort_order, is_archived FROM finance_goals ORDER BY sort_order, id"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $csv($out, [$row['id'], $row['name'], number_format((float)$row['target_amount'], 2, $dec, ''), number_format((float)$row['current_amount'], 2, $dec, ''), $row['color'], $row['deadline'], $row['note'], $row['sort_order'], $row['is_archived']]); } } catch (PDOException $e) {} } fclose($out); exit; } // ------------------------------------------------------------ // ЭКСПОРТ В SQL // ------------------------------------------------------------ if ($action === 'export_sql') { $filename = 'finance_export_' . date('Y-m-d_H-i-s') . '.sql'; header('Content-Type: application/sql; charset=utf-8'); header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Pragma: no-cache'); header('Expires: 0'); echo "-- Finance export\n"; echo "-- Generated: " . date('Y-m-d H:i:s') . "\n\n"; echo "SET NAMES utf8mb4;\n"; echo "SET FOREIGN_KEY_CHECKS = 0;\n\n"; echo "-- CATEGORIES\n"; $stmt = $pdo->query("SELECT id, type, name, color, sort_order FROM finance_categories ORDER BY id"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { echo "INSERT INTO finance_categories (id, type, name, color, sort_order) VALUES (" . (int)$row['id'] . ", " . $pdo->quote($row['type']) . ", " . $pdo->quote($row['name']) . ", " . $pdo->quote($row['color']) . ", " . (int)$row['sort_order'] . ");\n"; } echo "\n"; echo "-- ACCOUNTS\n"; $stmt = $pdo->query("SELECT id, name, color, sort_order FROM finance_accounts ORDER BY id"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { echo "INSERT INTO finance_accounts (id, name, color, sort_order) VALUES (" . (int)$row['id'] . ", " . $pdo->quote($row['name']) . ", " . $pdo->quote($row['color']) . ", " . (int)$row['sort_order'] . ");\n"; } echo "\n"; echo "-- FINANCE\n"; $stmt = $pdo->query("SELECT id, type, amount, category_id, account_id, date, note FROM finance ORDER BY id"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { echo "INSERT INTO finance (id, type, amount, category_id, account_id, date, note) VALUES (" . (int)$row['id'] . ", " . $pdo->quote($row['type']) . ", " . number_format((float)$row['amount'], 2, '.', '') . ", " . (int)$row['category_id'] . ", " . (int)$row['account_id'] . ", " . $pdo->quote($row['date']) . ", " . $pdo->quote($row['note']) . ");\n"; } echo "\n"; echo "-- ADJUSTMENTS\n"; $stmt = $pdo->query("SELECT id, account_id, amount, date, note FROM finance_adjustments ORDER BY id"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $accId = $row['account_id'] ? (int)$row['account_id'] : 'NULL'; echo "INSERT INTO finance_adjustments (id, account_id, amount, date, note) VALUES (" . (int)$row['id'] . ", " . $accId . ", " . number_format((float)$row['amount'], 2, '.', '') . ", " . $pdo->quote($row['date']) . ", " . $pdo->quote($row['note']) . ");\n"; } echo "\n"; echo "-- GOALS\n"; try { $stmt = $pdo->query("SELECT id, name, target_amount, current_amount, color, deadline, note, sort_order, is_archived FROM finance_goals ORDER BY id"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $deadline = $row['deadline'] ? $pdo->quote($row['deadline']) : 'NULL'; echo "INSERT INTO finance_goals (id, name, target_amount, current_amount, color, deadline, note, sort_order, is_archived) VALUES (" . (int)$row['id'] . ", " . $pdo->quote($row['name']) . ", " . number_format((float)$row['target_amount'], 2, '.', '') . ", " . number_format((float)$row['current_amount'], 2, '.', '') . ", " . $pdo->quote($row['color']) . ", " . $deadline . ", " . $pdo->quote($row['note']) . ", " . (int)$row['sort_order'] . ", " . (int)$row['is_archived'] . ");\n"; } } catch (PDOException $e) {} echo "\nSET FOREIGN_KEY_CHECKS = 1;\n"; exit; } // ------------------------------------------------------------ // ИМПОРТ ИЗ SQL // ------------------------------------------------------------ if ($action === 'import_sql') { if (empty($_FILES['sql_file']) || $_FILES['sql_file']['error'] !== UPLOAD_ERR_OK) { $error = 'Файл не загружен.'; } elseif ($_FILES['sql_file']['size'] > 20 * 1024 * 1024) { $error = 'Файл слишком большой (макс. 20 МБ).'; } else { $sql = file_get_contents($_FILES['sql_file']['tmp_name']); if ($sql === false) { $error = 'Не удалось прочитать файл.'; } else { $imported = 0; $skipped = 0; $skippedDetails = []; try { $pdo->beginTransaction(); $lines = preg_split('/\r\n|\r|\n/', $sql); $lineNum = 0; foreach ($lines as $line) { $lineNum++; $line = trim($line); if ($line === '' || $line[0] === '-' || $line[0] === '#') continue; if (!preg_match('/^INSERT\s+INTO\s+`?(finance|finance_categories|finance_adjustments|finance_accounts|finance_goals)`?\s/i', $line, $m)) continue; $table = strtolower($m[1]); if (!in_array($table, ['finance', 'finance_categories', 'finance_adjustments', 'finance_accounts', 'finance_goals'], true)) { $skipped++; continue; } try { $pdo->exec($line); $imported++; } catch (PDOException $e) { $skipped++; if ($e->getCode() !== '23000' && count($skippedDetails) < 30) $skippedDetails[] = "line $lineNum: " . $e->getMessage(); } } $pdo->commit(); $msg = 'Импорт SQL завершён. Добавлено записей: ' . $imported . ', пропущено: ' . $skipped . '.'; if (!empty($skippedDetails)) $msg .= ' Причины: ' . implode('; ', array_slice($skippedDetails, 0, 5)); $success = $msg; } catch (Exception $e) { $pdo->rollBack(); $error = 'Ошибка импорта SQL: ' . $e->getMessage(); } } } } // ------------------------------------------------------------ // УДАЛИТЬ ВСЕ ДАННЫЕ // ------------------------------------------------------------ if ($action === 'delete_all_data') { $confirmWord = trim($_POST['confirm_word'] ?? ''); $keepCats = !empty($_POST['keep_categories']); $keepAdj = !empty($_POST['keep_adjustments']); $keepAccs = !empty($_POST['keep_accounts']); $keepGoals = !empty($_POST['keep_goals']); if (mb_strtoupper($confirmWord, 'UTF-8') !== 'УДАЛИТЬ') { $error = 'Введите слово УДАЛИТЬ для подтверждения.'; } else { try { $pdo->beginTransaction(); $pdo->exec("DELETE FROM finance"); if (!$keepCats) $pdo->exec("DELETE FROM finance_categories"); if (!$keepAdj) $pdo->exec("DELETE FROM finance_adjustments"); if (!$keepAccs) { $pdo->exec("DELETE FROM finance_adjustments"); $pdo->exec("DELETE FROM finance_accounts"); } if (!$keepGoals) { try { $pdo->exec("DELETE FROM finance_goals"); } catch (PDOException $e) {} } $pdo->commit(); $deleted = ['операции']; if (!$keepCats) $deleted[] = 'категории'; if (!$keepAdj) $deleted[] = 'корректировки'; if (!$keepAccs) $deleted[] = 'счета'; if (!$keepGoals) $deleted[] = 'цели'; $success = 'Удалено: ' . implode(', ', $deleted) . '.'; } catch (Exception $e) { $pdo->rollBack(); $error = 'Ошибка удаления: ' . $e->getMessage(); } } } // --- ОПЕРАЦИИ --- if ($action === 'add_finance') { $type = ($_POST['type'] ?? '') === 'income' ? 'income' : 'expense'; $amount = isset($_POST['amount']) ? (float)str_replace(',', '.', $_POST['amount']) : 0; $catId = isset($_POST['category_id']) ? (int)$_POST['category_id'] : 0; $accId = isset($_POST['account_id']) ? (int)$_POST['account_id'] : 0; $date = isset($_POST['date']) ? trim($_POST['date']) : date('Y-m-d'); $note = isset($_POST['note']) ? trim($_POST['note']) : ''; if ($amount <= 0) $error = 'Сумма должна быть больше нуля.'; elseif ($catId <= 0) $error = 'Выберите категорию.'; elseif ($accId <= 0) $error = 'Выберите счёт.'; elseif (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) $error = 'Некорректная дата.'; else { try { $stmt = $pdo->prepare("INSERT INTO finance (type, amount, category_id, account_id, date, note) VALUES (?, ?, ?, ?, ?, ?)"); $stmt->execute([$type, $amount, $catId, $accId, $date, $note]); $success = ($type === 'income' ? 'Доход' : 'Расход') . ' добавлен.'; } catch (PDOException $e) { $error = 'Ошибка сохранения: ' . $e->getMessage(); } } } if ($action === 'update_finance') { $id = isset($_POST['id']) ? (int)$_POST['id'] : 0; $amount = isset($_POST['amount']) ? (float)str_replace(',', '.', $_POST['amount']) : 0; $catId = isset($_POST['category_id']) ? (int)$_POST['category_id'] : 0; $accId = isset($_POST['account_id']) ? (int)$_POST['account_id'] : 0; $date = isset($_POST['date']) ? trim($_POST['date']) : ''; $note = isset($_POST['note']) ? trim($_POST['note']) : ''; if ($id <= 0) $error = 'Неверный ID.'; elseif ($amount <= 0) $error = 'Сумма должна быть больше нуля.'; elseif ($catId <= 0) $error = 'Выберите категорию.'; elseif ($accId <= 0) $error = 'Выберите счёт.'; elseif (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) $error = 'Некорректная дата.'; else { try { $stmt = $pdo->prepare("UPDATE finance SET amount = ?, category_id = ?, account_id = ?, date = ?, note = ? WHERE id = ?"); $stmt->execute([$amount, $catId, $accId, $date, $note, $id]); $success = 'Запись обновлена.'; } catch (PDOException $e) { $error = 'Ошибка обновления: ' . $e->getMessage(); } } } if ($action === 'delete_finance') { $id = isset($_POST['id']) ? (int)$_POST['id'] : 0; if ($id > 0) { try { $pdo->prepare("DELETE FROM finance WHERE id = ?")->execute([$id]); $success = 'Запись удалена.'; } catch (PDOException $e) { $error = 'Ошибка удаления: ' . $e->getMessage(); } } } // --- КАТЕГОРИИ --- if ($action === 'add_category') { $type = ($_POST['cat_type'] ?? '') === 'income' ? 'income' : 'expense'; $name = trim($_POST['cat_name'] ?? ''); $color = trim($_POST['cat_color'] ?? '#78ff87'); if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) $color = '#78ff87'; if ($name === '') $error = 'Название категории не может быть пустым.'; else { try { $max = $pdo->prepare("SELECT COALESCE(MAX(sort_order), 0) + 1 FROM finance_categories WHERE type = ?"); $max->execute([$type]); $order = (int)$max->fetchColumn(); $stmt = $pdo->prepare("INSERT INTO finance_categories (type, name, color, sort_order) VALUES (?, ?, ?, ?)"); $stmt->execute([$type, $name, $color, $order]); $success = 'Категория добавлена.'; } catch (PDOException $e) { $error = ($e->getCode() === '23000') ? 'Такая категория уже есть.' : 'Ошибка: ' . $e->getMessage(); } } } if ($action === 'delete_category') { $id = isset($_POST['cat_id']) ? (int)$_POST['cat_id'] : 0; if ($id > 0) { try { $check = $pdo->prepare("SELECT COUNT(*) FROM finance WHERE category_id = ?"); $check->execute([$id]); if ((int)$check->fetchColumn() > 0) $error = 'Нельзя удалить: есть операции.'; else { $pdo->prepare("DELETE FROM finance_categories WHERE id = ?")->execute([$id]); $success = 'Категория удалена.'; } } catch (PDOException $e) { $error = 'Ошибка: ' . $e->getMessage(); } } } if ($action === 'update_category_color') { $id = isset($_POST['cat_id']) ? (int)$_POST['cat_id'] : 0; $color = trim($_POST['cat_color'] ?? ''); if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) $color = '#78ff87'; if ($id > 0) { try { $pdo->prepare("UPDATE finance_categories SET color = ? WHERE id = ?")->execute([$color, $id]); $success = 'Цвет обновлён.'; } catch (PDOException $e) { $error = 'Ошибка: ' . $e->getMessage(); } } } // --- СЧЕТА --- if ($action === 'add_account') { $name = trim($_POST['acc_name'] ?? ''); $color = trim($_POST['acc_color'] ?? '#6ec6ff'); if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) $color = '#6ec6ff'; if ($name === '') $error = 'Название счёта не может быть пустым.'; else { try { $max = $pdo->query("SELECT COALESCE(MAX(sort_order), 0) + 1 FROM finance_accounts"); $order = (int)$max->fetchColumn(); $stmt = $pdo->prepare("INSERT INTO finance_accounts (name, color, sort_order) VALUES (?, ?, ?)"); $stmt->execute([$name, $color, $order]); $success = 'Счёт добавлен.'; } catch (PDOException $e) { $error = ($e->getCode() === '23000') ? 'Такой счёт уже есть.' : 'Ошибка: ' . $e->getMessage(); } } } if ($action === 'delete_account') { $id = isset($_POST['acc_id']) ? (int)$_POST['acc_id'] : 0; if ($id > 0) { try { $check = $pdo->prepare("SELECT COUNT(*) FROM finance WHERE account_id = ?"); $check->execute([$id]); if ((int)$check->fetchColumn() > 0) $error = 'Нельзя удалить счёт: есть операции.'; else { $pdo->prepare("DELETE FROM finance_accounts WHERE id = ?")->execute([$id]); $success = 'Счёт удалён.'; } } catch (PDOException $e) { $error = 'Ошибка: ' . $e->getMessage(); } } } // --- РЕДАКТИРОВАНИЕ СЧЁТА (Имя, Цвет, Баланс) --- if ($action === 'edit_account') { $accId = isset($_POST['acc_id']) ? (int)$_POST['acc_id'] : 0; $name = trim($_POST['acc_name'] ?? ''); $color = trim($_POST['acc_color'] ?? '#6ec6ff'); $target = isset($_POST['target_balance']) ? (float)str_replace(',', '.', $_POST['target_balance']) : null; if ($accId <= 0 || $name === '') { $error = 'Неверные данные счёта.'; } else { if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) $color = '#6ec6ff'; try { $pdo->prepare("UPDATE finance_accounts SET name = ?, color = ? WHERE id = ?")->execute([$name, $color, $accId]); if ($target !== null) { $stmt = $pdo->prepare("SELECT COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN type='expense' THEN amount ELSE 0 END), 0) AS bal FROM finance WHERE account_id = ?"); $stmt->execute([$accId]); $currentBal = (float)$stmt->fetchColumn(); $stmt = $pdo->prepare("SELECT COALESCE(SUM(amount), 0) FROM finance_adjustments WHERE account_id = ?"); $stmt->execute([$accId]); $currentBal += (float)$stmt->fetchColumn(); $diff = $target - $currentBal; if (abs($diff) >= 0.01) { $note = 'Ручная корректировка счёта: ' . number_format($currentBal, 2, '.', ' ') . ' → ' . number_format($target, 2, '.', ' '); $stmt = $pdo->prepare("INSERT INTO finance_adjustments (account_id, amount, date, note) VALUES (?, ?, ?, ?)"); $stmt->execute([$accId, $diff, date('Y-m-d'), $note]); $success = 'Счёт обновлён, баланс скорректирован.'; } else { $success = 'Данные счёта обновлены.'; } } else { $success = 'Данные счёта обновлены.'; } } catch (PDOException $e) { $error = ($e->getCode() === '23000') ? 'Счёт с таким именем уже есть.' : 'Ошибка: ' . $e->getMessage(); } } } // --- КОРРЕКТИРОВКИ --- if ($action === 'add_adjustment') { $amount = isset($_POST['adj_amount']) ? (float)str_replace(',', '.', $_POST['adj_amount']) : 0; $accId = isset($_POST['adj_account_id']) ? (int)$_POST['adj_account_id'] : 0; $date = isset($_POST['adj_date']) ? trim($_POST['adj_date']) : date('Y-m-d'); $note = isset($_POST['adj_note']) ? trim($_POST['adj_note']) : ''; if ($amount == 0) $error = 'Сумма корректировки не может быть нулевой.'; elseif ($accId <= 0) $error = 'Выберите счёт для корректировки.'; elseif (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) $error = 'Некорректная дата корректировки.'; else { try { $stmt = $pdo->prepare("INSERT INTO finance_adjustments (account_id, amount, date, note) VALUES (?, ?, ?, ?)"); $stmt->execute([$accId, $amount, $date, $note]); $success = 'Корректировка добавлена.'; } catch (PDOException $e) { $error = 'Ошибка: ' . $e->getMessage(); } } } if ($action === 'delete_adjustment') { $id = isset($_POST['adj_id']) ? (int)$_POST['adj_id'] : 0; if ($id > 0) { try { $pdo->prepare("DELETE FROM finance_adjustments WHERE id = ?")->execute([$id]); $success = 'Корректировка удалена.'; } catch (PDOException $e) { $error = 'Ошибка: ' . $e->getMessage(); } } } // --- ЦЕЛИ --- if ($action === 'add_goal') { $name = trim($_POST['goal_name'] ?? ''); $target = isset($_POST['goal_target']) ? (float)str_replace(',', '.', $_POST['goal_target']) : 0; $current= isset($_POST['goal_current']) ? (float)str_replace(',', '.', $_POST['goal_current']) : 0; $color = trim($_POST['goal_color'] ?? '#78ff87'); $deadline = trim($_POST['goal_deadline'] ?? ''); $note = trim($_POST['goal_note'] ?? ''); if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) $color = '#78ff87'; if ($deadline !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $deadline)) $deadline = ''; if ($name === '') $error = 'Название цели не может быть пустым.'; elseif ($target <= 0) $error = 'Целевая сумма должна быть больше нуля.'; else { try { $max = $pdo->query("SELECT COALESCE(MAX(sort_order), 0) + 1 FROM finance_goals"); $order = (int)$max->fetchColumn(); $stmt = $pdo->prepare("INSERT INTO finance_goals (name, target_amount, current_amount, color, deadline, note, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)"); $stmt->execute([$name, $target, $current, $color, $deadline ?: null, $note, $order]); $success = 'Цель добавлена.'; } catch (PDOException $e) { $error = 'Ошибка: ' . $e->getMessage(); } } } if ($action === 'update_goal') { $id = isset($_POST['goal_id']) ? (int)$_POST['goal_id'] : 0; $name = trim($_POST['goal_name'] ?? ''); $target = isset($_POST['goal_target']) ? (float)str_replace(',', '.', $_POST['goal_target']) : 0; $current = isset($_POST['goal_current']) ? (float)str_replace(',', '.', $_POST['goal_current']) : 0; $color = trim($_POST['goal_color'] ?? '#78ff87'); $deadline = trim($_POST['goal_deadline'] ?? ''); $note = trim($_POST['goal_note'] ?? ''); if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) $color = '#78ff87'; if ($deadline !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $deadline)) $deadline = ''; if ($id <= 0) $error = 'Неверный ID цели.'; elseif ($name === '') $error = 'Название цели не может быть пустым.'; elseif ($target <= 0) $error = 'Целевая сумма должна быть больше нуля.'; else { try { $stmt = $pdo->prepare("UPDATE finance_goals SET name = ?, target_amount = ?, current_amount = ?, color = ?, deadline = ?, note = ? WHERE id = ?"); $stmt->execute([$name, $target, $current, $color, $deadline ?: null, $note, $id]); $success = 'Цель обновлена.'; } catch (PDOException $e) { $error = 'Ошибка: ' . $e->getMessage(); } } } if ($action === 'delete_goal') { $id = isset($_POST['goal_id']) ? (int)$_POST['goal_id'] : 0; if ($id > 0) { try { $pdo->prepare("DELETE FROM finance_goals WHERE id = ?")->execute([$id]); $success = 'Цель удалена.'; } catch (PDOException $e) { $error = 'Ошибка: ' . $e->getMessage(); } } } if ($action === 'archive_goal') { $id = isset($_POST['goal_id']) ? (int)$_POST['goal_id'] : 0; if ($id > 0) { try { $pdo->prepare("UPDATE finance_goals SET is_archived = 1 WHERE id = ?")->execute([$id]); $success = 'Цель закрыта и перенесена в архив.'; } catch (PDOException $e) { $error = 'Ошибка: ' . $e->getMessage(); } } } if ($action === 'unarchive_goal') { $id = isset($_POST['goal_id']) ? (int)$_POST['goal_id'] : 0; if ($id > 0) { try { $pdo->prepare("UPDATE finance_goals SET is_archived = 0 WHERE id = ?")->execute([$id]); $success = 'Цель возвращена из архива.'; } catch (PDOException $e) { $error = 'Ошибка: ' . $e->getMessage(); } } } if ($action === 'deposit_goal') { $id = isset($_POST['goal_id']) ? (int)$_POST['goal_id'] : 0; $amount = isset($_POST['deposit_amount']) ? (float)str_replace(',', '.', $_POST['deposit_amount']) : 0; if ($id > 0 && $amount != 0) { try { $pdo->prepare("UPDATE finance_goals SET current_amount = current_amount + ? WHERE id = ?")->execute([$amount, $id]); $success = 'Сумма добавлена к цели.'; } catch (PDOException $e) { $error = 'Ошибка: ' . $e->getMessage(); } } } // PRG if (empty($error)) { $redirectUrl = $_SERVER['PHP_SELF']; $queryParams = []; foreach (['period','from','to','an_month','hm_month','chart_month'] as $k) { if (isset($_GET[$k])) $queryParams[$k] = $_GET[$k]; } if (!empty($queryParams)) $redirectUrl .= '?' . http_build_query($queryParams); if (session_status() === PHP_SESSION_NONE) session_start(); if ($success) $_SESSION['fin_flash_success'] = $success; header('Location: ' . $redirectUrl); exit; } } if (session_status() === PHP_SESSION_NONE) session_start(); if (!empty($_SESSION['fin_flash_success'])) { $success = $_SESSION['fin_flash_success']; unset($_SESSION['fin_flash_success']); } // ============================================================ // Общий баланс // ============================================================ $totalIncomeAll = 0.0; $totalExpenseAll = 0.0; $totalAdjustments = 0.0; try { $stmt = $pdo->query("SELECT type, SUM(amount) AS total FROM finance GROUP BY type"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { if ($row['type'] === 'income') $totalIncomeAll = (float)$row['total']; if ($row['type'] === 'expense') $totalExpenseAll = (float)$row['total']; } } catch (PDOException $e) {} try { $stmt = $pdo->query("SELECT COALESCE(SUM(amount), 0) FROM finance_adjustments"); $totalAdjustments = (float)$stmt->fetchColumn(); } catch (PDOException $e) { $totalAdjustments = 0.0; } $totalBalance = $totalIncomeAll - $totalExpenseAll + $totalAdjustments; // ============================================================ // Сбережения (сумма расходов по категории "Сбережения") // ============================================================ $totalSavings = 0.0; try { $stmt = $pdo->query(" SELECT COALESCE(SUM(f.amount), 0) FROM finance f INNER JOIN finance_categories c ON c.id = f.category_id WHERE f.type = 'expense' AND c.name = 'Сбережения' "); $totalSavings = (float)$stmt->fetchColumn(); } catch (PDOException $e) {} // ============================================================ // Период // ============================================================ $period = $_GET['period'] ?? 'month'; $today = new DateTime('today'); switch ($period) { case 'day': $from = $to = $today->format('Y-m-d'); break; case 'week': $from = (clone $today)->modify('monday this week')->format('Y-m-d'); $to = (clone $today)->modify('sunday this week')->format('Y-m-d'); break; case 'year': $from = $today->format('Y') . '-01-01'; $to = $today->format('Y') . '-12-31'; break; case 'custom': $from = (isset($_GET['from']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $_GET['from'])) ? $_GET['from'] : $today->format('Y-m-01'); $to = (isset($_GET['to']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $_GET['to'])) ? $_GET['to'] : $today->format('Y-m-t'); break; case 'month': default: $period = 'month'; $from = $today->format('Y-m-01'); $to = $today->format('Y-m-t'); break; } // ============================================================ // Категории и Счета // ============================================================ $categories = ['income' => [], 'expense' => []]; try { $stmt = $pdo->query("SELECT * FROM finance_categories ORDER BY type, sort_order, id"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $categories[$row['type']][] = $row; } } catch (PDOException $e) {} $accounts = []; $accountBalances = []; try { $stmt = $pdo->query("SELECT * FROM finance_accounts ORDER BY sort_order, id"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $accounts[] = $row; $accountBalances[(int)$row['id']] = ['income' => 0.0, 'expense' => 0.0, 'adj' => 0.0, 'balance' => 0.0]; } $stmt = $pdo->query("SELECT account_id, type, SUM(amount) AS total FROM finance WHERE account_id IS NOT NULL GROUP BY account_id, type"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $aid = (int)$row['account_id']; if (isset($accountBalances[$aid])) { if ($row['type'] === 'income') $accountBalances[$aid]['income'] = (float)$row['total']; if ($row['type'] === 'expense') $accountBalances[$aid]['expense'] = (float)$row['total']; } } $stmt = $pdo->query("SELECT account_id, SUM(amount) AS total FROM finance_adjustments WHERE account_id IS NOT NULL GROUP BY account_id"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $aid = (int)$row['account_id']; if (isset($accountBalances[$aid])) $accountBalances[$aid]['adj'] = (float)$row['total']; } foreach ($accountBalances as $aid => &$b) { $b['balance'] = $b['income'] - $b['expense'] + $b['adj']; } unset($b); } catch (PDOException $e) {} // ============================================================ // Записи за период // ============================================================ $incomes = []; $expenses = []; try { $stmt = $pdo->prepare(" SELECT f.*, c.name AS category_name, c.color AS category_color, a.name AS account_name, a.color AS account_color FROM finance f LEFT JOIN finance_categories c ON c.id = f.category_id LEFT JOIN finance_accounts a ON a.id = f.account_id WHERE f.date BETWEEN ? AND ? ORDER BY f.date DESC, f.id DESC "); $stmt->execute([$from, $to]); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { if ($row['type'] === 'income') $incomes[] = $row; else $expenses[] = $row; } } catch (PDOException $e) {} $periodIncome = array_sum(array_column($incomes, 'amount')); $periodExpense = array_sum(array_column($expenses, 'amount')); $byCatExpense = []; foreach ($expenses as $r) { $k = $r['category_name'] ?? 'Без категории'; if (!isset($byCatExpense[$k])) $byCatExpense[$k] = ['sum' => 0.0, 'color' => $r['category_color'] ?? '#a5d8ff']; $byCatExpense[$k]['sum'] += (float)$r['amount']; } uasort($byCatExpense, fn($a, $b) => $b['sum'] <=> $a['sum']); $byCatIncome = []; foreach ($incomes as $r) { $k = $r['category_name'] ?? 'Без категории'; if (!isset($byCatIncome[$k])) $byCatIncome[$k] = ['sum' => 0.0, 'color' => $r['category_color'] ?? '#a5d8ff']; $byCatIncome[$k]['sum'] += (float)$r['amount']; } uasort($byCatIncome, fn($a, $b) => $b['sum'] <=> $a['sum']); // ============================================================ // Корректировки // ============================================================ $adjustments = []; try { $stmt = $pdo->query("SELECT a.*, acc.name AS acc_name FROM finance_adjustments a LEFT JOIN finance_accounts acc ON acc.id = a.account_id ORDER BY a.date DESC, a.id DESC"); $adjustments = $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (PDOException $e) {} // ============================================================ // Цели (активные и архивные) // ============================================================ $goals = []; $goalsArchived = []; try { $stmt = $pdo->query("SELECT * FROM finance_goals ORDER BY sort_order, id"); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { if (!empty($row['is_archived'])) $goalsArchived[] = $row; else $goals[] = $row; } } catch (PDOException $e) {} // ============================================================ // АНАЛИЗ: месяцы, прогноз, 50/30/20, перерасход, тепловая карта // ============================================================ function parseMonthParam($param, $fallbackDate) { if ($param && preg_match('/^\d{4}-\d{2}$/', $param)) { $y = (int)substr($param, 0, 4); $m = (int)substr($param, 5, 2); if ($m >= 1 && $m <= 12) return [$y, $m]; } return [(int)$fallbackDate->format('Y'), (int)$fallbackDate->format('n')]; } list($anYear, $anMonth) = parseMonthParam($_GET['an_month'] ?? '', $today); $anFirst = new DateTime(sprintf('%04d-%02d-01', $anYear, $anMonth)); $anLast = (clone $anFirst)->modify('last day of this month'); list($hmYear, $hmMonth) = parseMonthParam($_GET['hm_month'] ?? '', $today); $hmFirst = new DateTime(sprintf('%04d-%02d-01', $hmYear, $hmMonth)); $hmLast = (clone $hmFirst)->modify('last day of this month'); // Месяц для графика динамики list($chYear, $chMonth) = parseMonthParam($_GET['chart_month'] ?? '', $today); $chFirst = new DateTime(sprintf('%04d-%02d-01', $chYear, $chMonth)); $monthNamesRu = [ 1 => 'Январь', 2 => 'Февраль', 3 => 'Март', 4 => 'Апрель', 5 => 'Май', 6 => 'Июнь', 7 => 'Июль', 8 => 'Август', 9 => 'Сентябрь', 10 => 'Октябрь', 11 => 'Ноябрь', 12 => 'Декабрь', ]; function buildUrlWithParams($baseParams) { $url = $_SERVER['PHP_SELF']; $params = array_merge($_GET, $baseParams); foreach ($params as $k => $v) { if ($v === '' || $v === null) unset($params[$k]); } if (!empty($params)) $url .= '?' . http_build_query($params); return $url; } $anPrev = (clone $anFirst)->modify('-1 month'); $anNext = (clone $anFirst)->modify('+1 month'); $hmPrev = (clone $hmFirst)->modify('-1 month'); $hmNext = (clone $hmFirst)->modify('+1 month'); $anPrevUrl = buildUrlWithParams(['an_month' => $anPrev->format('Y-m')]); $anNextUrl = buildUrlWithParams(['an_month' => $anNext->format('Y-m')]); $hmPrevUrl = buildUrlWithParams(['hm_month' => $hmPrev->format('Y-m')]); $hmNextUrl = buildUrlWithParams(['hm_month' => $hmNext->format('Y-m')]); // Прогноз $forecast = null; $currentMonthStart = $today->format('Y-m-01'); $currentMonthEnd = $today->format('Y-m-t'); $stmt = $pdo->prepare("SELECT COALESCE(SUM(amount), 0) FROM finance WHERE type='expense' AND date BETWEEN ? AND ?"); $stmt->execute([$currentMonthStart, $today->format('Y-m-d')]); $spentThisMonth = (float)$stmt->fetchColumn(); $daysPassed = (int)$today->format('j'); $daysInMonth = (int)$today->format('t'); $daysLeft = max(0, $daysInMonth - $daysPassed); $avgPerDay = $daysPassed > 0 ? $spentThisMonth / $daysPassed : 0; $forecastTotal = $spentThisMonth + $avgPerDay * $daysLeft; $stmt = $pdo->prepare("SELECT COALESCE(SUM(amount), 0) FROM finance WHERE type='income' AND date BETWEEN ? AND ?"); $stmt->execute([$currentMonthStart, $currentMonthEnd]); $incomeThisMonth = (float)$stmt->fetchColumn(); $forecast = [ 'spent' => $spentThisMonth, 'income' => $incomeThisMonth, 'avg_per_day' => $avgPerDay, 'days_passed' => $daysPassed, 'days_left' => $daysLeft, 'days_total' => $daysInMonth, 'forecast' => $forecastTotal, 'remainder' => $incomeThisMonth - $forecastTotal, ]; // Норма сбережений (savings rate) $savingsThisMonth = 0.0; try { $stmt = $pdo->prepare(" SELECT COALESCE(SUM(f.amount), 0) FROM finance f INNER JOIN finance_categories c ON c.id = f.category_id WHERE f.type='expense' AND c.name='Сбережения' AND f.date BETWEEN ? AND ? "); $stmt->execute([$currentMonthStart, $today->format('Y-m-d')]); $savingsThisMonth = (float)$stmt->fetchColumn(); } catch (PDOException $e) {} $savingsRate = $incomeThisMonth > 0 ? ($savingsThisMonth / $incomeThisMonth) * 100 : 0.0; $netSavings = $incomeThisMonth - $spentThisMonth; $netSavingsRate = $incomeThisMonth > 0 ? ($netSavings / $incomeThisMonth) * 100 : 0.0; // 50/30/20 $categoryGroups = [ 'Продукты' => 'needs', 'Жильё' => 'needs', '167 кв.' => 'needs', 'Ипотека' => 'needs', 'Транспорт' => 'needs', 'Автомобиль' => 'needs', 'Здоровье' => 'needs', 'Связь' => 'needs', 'Телефон и Интернет' => 'needs', 'Дом' => 'needs', 'Кафе' => 'wants', 'Досуг' => 'wants', 'Одежда' => 'wants', 'Развлечения' => 'wants', 'Подарки' => 'wants', 'Спорт' => 'wants', 'Другое' => 'wants', 'Сбережения' => 'savings', 'Инвестиции' => 'savings', ]; $allExpenseCats = []; foreach ($categories['expense'] as $c) $allExpenseCats[] = $c['name']; $defaultGroupsJson = json_encode($categoryGroups, JSON_UNESCAPED_UNICODE); $allExpenseCatsJson = json_encode($allExpenseCats, JSON_UNESCAPED_UNICODE); $ruleBuckets = [ 'needs' => ['label' => 'Необходимое', 'target' => 50, 'sum' => 0.0, 'color' => '#6ec6ff'], 'wants' => ['label' => 'Желания', 'target' => 30, 'sum' => 0.0, 'color' => '#ffa94d'], 'savings' => ['label' => 'Сбережения', 'target' => 20, 'sum' => 0.0, 'color' => '#78ff87'], ]; foreach ($byCatExpense as $catName => $info) { $group = $categoryGroups[$catName] ?? 'wants'; $ruleBuckets[$group]['sum'] += $info['sum']; } $ruleBase = $periodIncome > 0 ? $periodIncome : max($periodExpense, 1); $ruleResult = []; foreach ($ruleBuckets as $k => $b) { $pct = $ruleBase > 0 ? ($b['sum'] / $ruleBase) * 100 : 0; $ruleResult[$k] = [ 'label' => $b['label'], 'sum' => $b['sum'], 'pct' => $pct, 'target' => $b['target'], 'color' => $b['color'], 'status' => $pct > $b['target'] + 5 ? 'over' : ($pct < $b['target'] - 10 ? 'under' : 'ok'), ]; } // Перерасход $overrunWarnings = []; try { $threeMonthsAgoStart = (clone $anFirst)->modify('-3 months')->format('Y-m-01'); $prevMonthEnd = (clone $anFirst)->modify('-1 day')->format('Y-m-d'); $stmt = $pdo->prepare("SELECT c.name AS cat_name, SUM(f.amount) AS total FROM finance f LEFT JOIN finance_categories c ON c.id = f.category_id WHERE f.type='expense' AND f.date BETWEEN ? AND ? GROUP BY c.name"); $stmt->execute([$threeMonthsAgoStart, $prevMonthEnd]); $prevTotals = []; while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) if (!empty($row['cat_name'])) $prevTotals[$row['cat_name']] = (float)$row['total'] / 3; $stmt = $pdo->prepare("SELECT c.name AS cat_name, c.color AS cat_color, SUM(f.amount) AS total FROM finance f LEFT JOIN finance_categories c ON c.id = f.category_id WHERE f.type='expense' AND f.date BETWEEN ? AND ? GROUP BY c.name, c.color"); $stmt->execute([$anFirst->format('Y-m-01'), $anLast->format('Y-m-d')]); $currentTotals = []; while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) if (!empty($row['cat_name'])) $currentTotals[$row['cat_name']] = ['sum' => (float)$row['total'], 'color' => $row['cat_color'] ?? '#adb5bd']; foreach ($currentTotals as $catName => $info) { $prevAvg = $prevTotals[$catName] ?? 0; if ($prevAvg <= 0) continue; $diffPct = (($info['sum'] - $prevAvg) / $prevAvg) * 100; if ($diffPct >= 20) $overrunWarnings[] = ['name' => $catName, 'color' => $info['color'], 'current' => $info['sum'], 'avg' => $prevAvg, 'pct' => $diffPct]; } usort($overrunWarnings, fn($a, $b) => $b['pct'] <=> $a['pct']); } catch (PDOException $e) {} // Тепловая карта $heatmap = []; try { $stmt = $pdo->prepare("SELECT date, SUM(amount) AS total FROM finance WHERE type='expense' AND date BETWEEN ? AND ? GROUP BY date"); $stmt->execute([$hmFirst->format('Y-m-01'), $hmLast->format('Y-m-d')]); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) $heatmap[$row['date']] = (float)$row['total']; } catch (PDOException $e) {} $heatmapMax = !empty($heatmap) ? max($heatmap) : 0; $hmFirstDayDow = (int)$hmFirst->format('N'); $hmDaysInMonth = (int)$hmLast->format('j'); // ============================================================ // ГРАФИК ДИНАМИКИ ПО МЕСЯЦАМ (12 месяцев) // ============================================================ $chartData = []; $chartStart = (clone $chFirst)->modify('-11 months'); $chartEnd = (clone $chFirst)->modify('last day of this month'); try { $stmt = $pdo->prepare(" SELECT DATE_FORMAT(date, '%Y-%m') AS ym, SUM(CASE WHEN type='income' THEN amount ELSE 0 END) AS inc, SUM(CASE WHEN type='expense' THEN amount ELSE 0 END) AS exp FROM finance WHERE date BETWEEN ? AND ? GROUP BY ym ORDER BY ym "); $stmt->execute([$chartStart->format('Y-m-01'), $chartEnd->format('Y-m-d')]); $map = []; while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $map[$row['ym']] = ['inc' => (float)$row['inc'], 'exp' => (float)$row['exp']]; } $cursor = clone $chartStart; for ($i = 0; $i < 12; $i++) { $ym = $cursor->format('Y-m'); $chartData[] = [ 'ym' => $ym, 'label' => $monthNamesRu[(int)$cursor->format('n')] . ' ' . $cursor->format('y'), 'inc' => $map[$ym]['inc'] ?? 0.0, 'exp' => $map[$ym]['exp'] ?? 0.0, ]; $cursor->modify('+1 month'); } } catch (PDOException $e) {} $chartJson = json_encode($chartData, JSON_UNESCAPED_UNICODE); $chPrevUrl = buildUrlWithParams(['chart_month' => (clone $chFirst)->modify('-1 month')->format('Y-m')]); $chNextUrl = buildUrlWithParams(['chart_month' => (clone $chFirst)->modify('+1 month')->format('Y-m')]); $chMonthNameRu = $monthNamesRu[$chMonth] . ' ' . $chYear; // ============================================================ // Хелперы // ============================================================ function fmtMoney($v) { return number_format((float)$v, 2, '.', ' ') . ' ₽'; } function e($s) { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); } function buildList($rows, $type, $categories, $accounts) { if (empty($rows)) return '
За выбранный период записей нет.
'; $optionsFor = function($catId) use ($categories, $type) { $out = ''; foreach ($categories[$type] as $c) { $sel = ((int)$c['id'] === (int)$catId) ? 'selected' : ''; $out .= ''; } return $out; }; $accountOptions = function($accId) use ($accounts) { $out = ''; foreach ($accounts as $a) { $sel = ((int)$a['id'] === (int)$accId) ? 'selected' : ''; $out .= ''; } return $out; }; // Группировка по категориям $groups = []; foreach ($rows as $r) { $catName = $r['category_name'] ?? 'Без категории'; $catColor = $r['category_color'] ?? '#888'; $key = $catName . '|' . $catColor; if (!isset($groups[$key])) { $groups[$key] = [ 'name' => $catName, 'color' => $catColor, 'sum' => 0.0, 'items' => [], ]; } $groups[$key]['sum'] += (float)$r['amount']; $groups[$key]['items'][] = $r; } uasort($groups, fn($a, $b) => $b['sum'] <=> $a['sum']); $html = ''; foreach ($groups as $g) { $groupSum = fmtMoney($g['sum']); $groupColor = e($g['color']); $groupName = e($g['name']); $groupKey = md5($g['name'] . '|' . $g['color'] . '|' . $type); $count = count($g['items']); $html .= ''; } return $html; } $expensesHtml = buildList($expenses, 'expense', $categories, $accounts); $incomesHtml = buildList($incomes, 'income', $categories, $accounts); $adjustmentsHtml = ''; if (empty($adjustments)) { $adjustmentsHtml = '
Корректировок нет.
'; } else { foreach ($adjustments as $a) { $amt = (float)$a['amount']; $cls = $amt >= 0 ? 'income' : 'expense'; $sign = $amt >= 0 ? '+' : '−'; $val = fmtMoney(abs($amt)); $d = date('d.m.Y', strtotime($a['date'])); $note = e($a['note']); $accName = e($a['acc_name'] ?? 'Общий баланс'); $id = (int)$a['id']; $adjustmentsHtml .= '
' . '
' . '
' . $d . '
' . '
Корректировка (' . $accName . ')
' . '
' . $note . '
' . '
' . $sign . $val . '
' . '
' . '' . '
' . '
'; } } $chartExpenseJson = json_encode(array_map(fn($k, $v) => ['name' => $k, 'sum' => $v['sum'], 'color' => $v['color']], array_keys($byCatExpense), array_values($byCatExpense)), JSON_UNESCAPED_UNICODE); $chartIncomeJson = json_encode(array_map(fn($k, $v) => ['name' => $k, 'sum' => $v['sum'], 'color' => $v['color']], array_keys($byCatIncome), array_values($byCatIncome)), JSON_UNESCAPED_UNICODE); $catsJson = json_encode($categories, JSON_UNESCAPED_UNICODE); $selDay = $period === 'day' ? 'selected' : ''; $selWeek = $period === 'week' ? 'selected' : ''; $selMonth = $period === 'month' ? 'selected' : ''; $selYear = $period === 'year' ? 'selected' : ''; $selCustom = $period === 'custom' ? 'selected' : ''; $messagesHtml = ''; if ($error) $messagesHtml .= '
' . e($error) . '
'; if ($success) $messagesHtml .= '
' . e($success) . '
'; $balanceStr = fmtMoney($totalBalance); $incomeAllStr = fmtMoney($totalIncomeAll); $expenseAllStr = fmtMoney($totalExpenseAll); $adjStr = fmtMoney($totalAdjustments); $savingsStr = fmtMoney($totalSavings); $periodIncomeStr = fmtMoney($periodIncome); $periodExpenseStr = fmtMoney($periodExpense); $todayStr = $today->format('Y-m-d'); // HTML аналитики $forecastHtml = ''; if ($forecast) { $overForecast = $forecast['remainder'] < 0; $savingsRateClass = $savingsRate >= 20 ? 'good' : ($savingsRate >= 10 ? 'warn' : 'danger'); $netRateClass = $netSavingsRate >= 20 ? 'good' : ($netSavingsRate >= 10 ? 'warn' : ($netSavingsRate >= 0 ? 'warn' : 'danger')); $forecastHtml = '
' . '
Потрачено
' . fmtMoney($forecast['spent']) . '
день ' . $forecast['days_passed'] . ' из ' . $forecast['days_total'] . '
' . '
Средний расход/день
' . fmtMoney($forecast['avg_per_day']) . '
осталось дней: ' . $forecast['days_left'] . '
' . '
Прогноз на месяц
' . fmtMoney($forecast['forecast']) . '
доход: ' . fmtMoney($forecast['income']) . '
' . '
' . ($overForecast ? 'Дефицит' : 'Остаток') . '
' . fmtMoney(abs($forecast['remainder'])) . '
' . ($overForecast ? 'прогноз превышает доход' : 'прогноз в пределах дохода') . '
' . '
Норма сбережений
' . number_format($savingsRate, 1, '.', ' ') . '%
отложено: ' . fmtMoney($savingsThisMonth) . '
' . '
Чистая норма
' . number_format($netSavingsRate, 1, '.', ' ') . '%
' . ($netSavings >= 0 ? 'профицит: ' : 'дефицит: ') . fmtMoney(abs($netSavings)) . '
' . '
'; } $ruleHtml = ''; foreach (['needs', 'wants', 'savings'] as $key) { $b = $ruleResult[$key]; $barPct = min(100, $b['pct']); $overClass = $b['status'] === 'over' ? 'over' : ($b['status'] === 'under' ? 'under' : 'ok'); $statusText = $b['status'] === 'over' ? 'перерасход' : ($b['status'] === 'under' ? 'запас' : 'в норме'); $ruleHtml .= '
' . '
' . e($b['label']) . '' . fmtMoney($b['sum']) . ' · ' . round($b['pct'], 1) . '% (цель ' . $b['target'] . '%)
' . '
' . '
' . $statusText . '
' . '
'; } $warningsHtml = ''; if (empty($overrunWarnings)) { $warningsHtml = '
Перерасходов по категориям нет. Всё в норме.
'; } else { foreach ($overrunWarnings as $w) { $warningsHtml .= '
' . e($w['name']) . '+' . round($w['pct'], 0) . '%
Сейчас: ' . fmtMoney($w['current']) . ' · Среднее за 3 месяца: ' . fmtMoney($w['avg']) . '
'; } } $heatmapHtml = '
'; foreach (['Пн','Вт','Ср','Чт','Пт','Сб','Вс'] as $d) $heatmapHtml .= '
' . $d . '
'; $heatmapHtml .= '
'; for ($i = 1; $i < $hmFirstDayDow; $i++) $heatmapHtml .= '
'; for ($d = 1; $d <= $hmDaysInMonth; $d++) { $dateStr = $hmFirst->format('Y-m-') . str_pad($d, 2, '0', STR_PAD_LEFT); $sum = $heatmap[$dateStr] ?? 0; $intensity = $heatmapMax > 0 ? $sum / $heatmapMax : 0; if ($sum <= 0) { $bg = '#161616'; $txt = '#444'; } else { $r = 80 + (int)(175 * $intensity); $g = 160 - (int)(120 * $intensity); $b = 80 - (int)(40 * $intensity); $bg = 'rgba(' . $r . ',' . $g . ',' . $b . ',' . (0.3 + 0.7 * $intensity) . ')'; $txt = $intensity > 0.5 ? '#fff' : '#ccc'; } $isToday = ($dateStr === $today->format('Y-m-d')); $heatmapHtml .= '
' . $d . '' . ($sum > 0 ? '' . number_format($sum, 0, '.', ' ') . '' : '') . '
'; } $cellsUsed = $hmFirstDayDow - 1 + $hmDaysInMonth; $remainder = $cellsUsed % 7; if ($remainder > 0) for ($i = 0; $i < 7 - $remainder; $i++) $heatmapHtml .= '
'; $heatmapHtml .= '
'; $hmMonthNameRu = $monthNamesRu[$hmMonth] . ' ' . $hmYear; $anMonthNameRu = $monthNamesRu[$anMonth] . ' ' . $anYear; // HTML целей (активные) $goalsHtml = ''; if (empty($goals)) { $goalsHtml = '
Целей пока нет. Добавьте первую цель ниже.
'; } else { foreach ($goals as $g) { $id = (int)$g['id']; $name = e($g['name']); $target = (float)$g['target_amount']; $current = (float)$g['current_amount']; $color = e($g['color']); $deadline = $g['deadline'] ? date('d.m.Y', strtotime($g['deadline'])) : ''; $note = e($g['note']); $pct = $target > 0 ? min(100, ($current / $target) * 100) : 0; $remaining = max(0, $target - $current); $done = $current >= $target; $deadlineClass = ''; if ($g['deadline']) { $daysLeft = (int)((strtotime($g['deadline']) - strtotime($todayStr)) / 86400); if ($daysLeft < 0) $deadlineClass = 'danger'; elseif ($daysLeft < 30) $deadlineClass = 'warn'; } $goalsHtml .= '
' . '
' . '
' . $name . '' . ($done ? ' ✓' : '') . '
' . '
' . '' . '' . '' . '
' . '
' . ($note ? '
' . $note . '
' : '') . '
' . '
' . '' . fmtMoney($current) . ' / ' . fmtMoney($target) . '' . '' . round($pct, 1) . '%' . '
' . '
' . 'Осталось: ' . fmtMoney($remaining) . '' . ($deadline ? 'до ' . $deadline . '' : '') . '
' . '
' . '' . '' . '' . '' . '
' . '
'; } } // HTML архива целей $goalsArchivedHtml = ''; if (!empty($goalsArchived)) { $goalsArchivedHtml .= '

📦 Архив целей

'; foreach ($goalsArchived as $g) { $id = (int)$g['id']; $name = e($g['name']); $target = (float)$g['target_amount']; $current = (float)$g['current_amount']; $color = e($g['color']); $pct = $target > 0 ? min(100, ($current / $target) * 100) : 0; $goalsArchivedHtml .= '
' . '
' . '
' . $name . '
' . '
' . '' . '' . '
' . '
' . '
' . '
' . fmtMoney($current) . ' / ' . fmtMoney($target) . '' . round($pct, 1) . '%
' . '
'; } } // Общий прогресс целей (только активные) $goalsTotalTarget = 0.0; $goalsTotalCurrent = 0.0; foreach ($goals as $g) { $goalsTotalTarget += (float)$g['target_amount']; $goalsTotalCurrent += (float)$g['current_amount']; } $goalsOverallPct = $goalsTotalTarget > 0 ? min(100, ($goalsTotalCurrent / $goalsTotalTarget) * 100) : 0; $goalsOverallHtml = ''; if (!empty($goals)) { $goalsOverallHtml = '
' . '
Всего по целям' . fmtMoney($goalsTotalCurrent) . ' / ' . fmtMoney($goalsTotalTarget) . '
' . '
' . '
' . round($goalsOverallPct, 1) . '%
' . '
'; } ?> Финансы 100+

Финансы 100+

Общий баланс
КРЦ:
Доходы
Расходы
Сбережения (50/30/20)
За период: + / −

Расходы по категориям

Корректировки баланса

Корректировки привязаны к конкретным счетам. Они не влияют на диаграммы доходов и расходов, но меняют баланс выбранного счёта и общий баланс.

История корректировок

Прогноз расходов до конца месяца

Правило 50/30/20

Необходимое — до 50% дохода, желания — до 30%, сбережения — минимум 20%. Считается от доходов за выбранный период.

Категории по группам
Необходимое (50%)
Желания (30%)
Сбережения (20%)

Настройки сохраняются в этом браузере (localStorage) и не влияют на других пользователей.

Перерасход по категориям

Сравнение выбранного месяца со средним за 3 предыдущих. Показаны категории, где превышение ≥ 20%.

Тепловая карта расходов

Цвет ячейки — сумма трат за день. Чем ярче, тем больше потрачено. Наведите курсор (или тапните) на день, чтобы увидеть сумму.

Меньше
Больше

График динамики по месяцам

Доходы и расходы за последние 12 месяцев (до выбранного месяца включительно).

Доходы
Расходы
Сальдо

Мои цели и накопления

Цели помогают отслеживать накопления. Пополняйте цель кнопкой «+» — сумма сохранится. Кнопка 📦 закрывает цель и переносит её в архив (не удаляя).

Добавить цель

Категории расходов

Категории доходов

Мои счета

Баланс счёта = все доходы по счёту − все расходы по счёту + корректировки. Для изменения названия дважды кликните по имени.

0, 'expense' => 0, 'adj' => 0, 'balance' => 0]; $bal = (float)$b['balance']; $balClass = $bal >= 0 ? 'income' : 'expense'; ?>

FAQ

Как работает «Финансы 100+»

1. Общая идея

Сайт — это личный учёт денег: вы записываете доходы и расходы по категориям и счетам, а система автоматически считает баланс, показывает статистику, прогнозы и помогает копить на цели. Все данные хранятся в вашей базе MySQL, а настройки вроде правила 50/30/20 — в вашем браузере.

2. Математика баланса

Общий баланс = сумма всех доходов − сумма всех расходов + сумма всех корректировок. Корректировка — это ручная правка баланса (например, если вы сняли наличные или перенесли деньги между счетами, и баланс нужно «подогнать»).

Баланс счёта = доходы по этому счёту − расходы по этому счёту + корректировки по этому счёту. Если вы редактируете счёт и вводите «Новый баланс», система автоматически создаёт корректировку на разницу: `новый − текущий`. Так баланс всегда сходится, а история изменений сохраняется.

Сбережения (расходы) — это сумма всех операций типа «расход» по категории с названием «Сбережения». Такие траты не уменьшают ваш капитал, а откладываются. Они показываются отдельной строкой под балансом.

3. Вкладки — что где находится

Расходы — список всех расходов за выбранный период. Записи сгруппированы по категориям (свёрнуты по умолчанию). В заголовке группы: цвет, название, количество записей и общая сумма. Нажмите на заголовок, чтобы раскрыть/свернуть. Внутри каждой строки: дата, категория, примечание, сумма, кнопки ✎ (редактировать) и × (удалить).

Доходы — то же самое, но для доходов. Категории доходов свои (зарплата, отпускные, подарки и т.д.).

Корректировки — история ручных правок баланса. Здесь можно добавить новую корректировку: выберите счёт, укажите сумму (можно отрицательную), дату и примечание. Корректировки меняют баланс счёта, но не попадают в диаграммы доходов/расходов.

Анализ — четыре блока: прогноз расходов до конца месяца, правило 50/30/20, перерасход по категориям, тепловая карта расходов и график динамики по месяцам. Подробно о каждом — ниже.

Цели — список ваших финансовых целей с прогресс-барами. Каждую цель можно пополнять (кнопка «+ Сумма»), редактировать (✎), закрыть и перенести в архив (📦) или удалить (×). Архивные цели хранятся, но не учитываются в общем прогрессе под балансом.

Категории — управление категориями расходов и доходов. Можно добавить новую категорию с цветом, изменить цвет существующей или удалить (если по ней нет операций).

Счета — список ваших счетов (карты, наличные, вклады). У каждого счёта свой цвет и баланс. Двойной клик по названию — редактирование. Кнопка ✎ открывает окно с изменением названия, цвета и баланса.

Настройки — экспорт в CSV/SQL, импорт из SQL и удаление всех данных с возможностью сохранить категории, счета, корректировки и цели.

4. Период и поиск

Вверху страницы есть выбор периода: день, неделя, месяц, год или произвольный диапазон. От выбранного периода зависят списки операций, суммы «За период» и диаграммы. Кнопка «Показать» применяет изменения.

На вкладках «Расходы» и «Доходы» есть строка поиска. Она ищет по примечанию, названию категории, названию счёта, сумме и дате в формате дд.мм.гггг. Если ввести «бензин» — останутся только записи с этим словом в примечании. Если ввести «Ипотека» — только записи этой категории.

5. Анализ: прогноз и норма сбережений

Прогноз расходов до конца месяца считается так: `средний расход в день × оставшиеся дни + уже потрачено`. Средний расход = потрачено / прошедшие дни. Если прогноз превышает доход за месяц, блок «Остаток» подсвечивается красным и показывает дефицит.

Норма сбережений = сумма расходов по категории «Сбережения» / доход за месяц × 100%. Если вы откладываете 20% и больше — зелёный, 10–20% — жёлтый, меньше 10% — красный.

Чистая норма = (доход − расход) / доход × 100%. Это реальный профицит или дефицит месяца. Если вы тратите больше, чем зарабатываете, норма будет отрицательной.

6. Анализ: правило 50/30/20

Правило делит все расходы на три группы: Необходимое (до 50% дохода), Желания (до 30%), Сбережения (минимум 20%). Категории распределяются по группам автоматически по названию. Если название нестандартное — по умолчанию попадает в «Желания».

Кнопка «⚙ Настроить категории» открывает панель, где можно вручную указать, к какой группе относится каждая категория. Настройки сохраняются в localStorage вашего браузера — на других устройствах они не появятся. Кнопка «Сбросить к дефолту» возвращает распределение по умолчанию.

Для каждой группы показывается: сумма, процент от дохода, целевой процент и статус: «в норме», «перерасход» (превышение на 5%+) или «запас» (недобор на 10%+).

7. Анализ: перерасход по категориям

Система берёт выбранный месяц (стрелки ‹ ›) и сравнивает траты по каждой категории со средним за 3 предыдущих месяца. Если текущий месяц превышает среднее на 20% и больше — категория попадает в список предупреждений. Так видно, где вы «сорвались» и потратили больше обычного.

8. Анализ: тепловая карта

Календарь на выбранный месяц. Каждая ячейка — день, цвет зависит от суммы трат: чем ярче и краснее — тем больше потрачено. Наведите курсор (или тапните) — появится подсказка с точной суммой. Сегодняшний день обведён зелёным.

9. Анализ: график динамики

Линейный график доходов и расходов за последние 12 месяцев (до выбранного месяца включительно). Синяя линия — доходы, красная — расходы, зелёная — сальдо (разница). Стрелки ‹ › переключают окно на предыдущий/следующий месяц. График помогает увидеть тренд: растут ли расходы, стабилен ли доход и в каком месяце был «провал».

10. Цели и накопления

Каждая цель — это название, целевая сумма, уже накопленная сумма, необязательный дедлайн, цвет и примечание. Прогресс-бар показывает процент выполнения. Под ним: «Осталось» и дедлайн (жёлтый — меньше 30 дней, красный — просрочен).

Пополнение: введите сумму в поле «+ Сумма» и нажмите «+». Сумма прибавится к текущей. Чтобы изменить параметры цели — нажмите ✎. Чтобы закрыть и убрать с главного экрана — 📦 (цель уходит в «Архив целей» внизу вкладки, но остаётся в базе). Вернуть из архива — кнопка ↺. Удалить навсегда — ×.

Общий прогресс под балансом = сумма накоплений по всем активным целям / сумма целевых сумм × 100%. Архивные цели в этом расчёте не участвуют.

11. Экспорт и импорт

Экспорт в CSV — скачивает файл, который открывается в Excel. Можно выбрать «Всё», «Только операции», «Только категории», «Только корректировки» или «Только цели». Разделитель `;`, кодировка UTF-8 с BOM — Excel откроет без «кракозябр».

Экспорт в SQL — скачивает .sql-файл с INSERT-ами для всех таблиц: категории, счета, операции, корректировки, цели. Его можно импортировать в другую базу или сохранить как резервную копию.

Импорт из SQL — загружаете .sql-файл, система выполняет только INSERT-ы в таблицы `finance*`. Старые данные не удаляются. Если запись с таким `id` уже есть — она пропускается (ошибка 23000).

12. Удаление данных

В «Настройках» внизу — блок «Удалить все данные». Нужно ввести слово УДАЛИТЬ заглавными. Можно сохранить категории, счета, корректировки и цели, поставив галочки — тогда удалятся только операции. Действие необратимо, поэтому сначала сделайте экспорт.

13. Технические детали

Страница работает на PHP + PDO + MySQL. Все операции защищены от SQL-инъекций (prepared statements). После добавления/редактирования/удаления используется PRG (Post/Redirect/Get), чтобы при обновлении страницы действие не повторилось. Сообщения об успехе/ошибке передаются через сессию.

Состояние вкладок (какая открыта) и правило 50/30/20 сохраняются в localStorage браузера. Свёрнутость групп категорий по умолчанию всегда сброшена — при обновлении страницы все группы свёрнуты.

Структура таблиц: `finance` (операции), `finance_categories` (категории), `finance_accounts` (счета), `finance_adjustments` (корректировки), `finance_goals` (цели). Таблица целей создаётся автоматически при первом заходе, если её нет.

14. Быстрый старт

1. Откройте вкладку «Счета» и добавьте свой счёт (например, «Карта»). 2. Откройте «Категории» и проверьте/добавьте нужные категории расходов и доходов. 3. Нажмите «+ Добавить» на вкладке «Расходы» или «Доходы» и внесите первую операцию. 4. Перейдите в «Анализ» — увидите прогноз и графики. 5. Создайте цель на вкладке «Цели» и пополняйте её. Готово — теперь вы контролируете свои финансы.

Экспорт в Excel

Скачивается CSV-файл, который открывается в Excel.

Экспорт в SQL

Скачивается .sql-файл с INSERT-ами для всех таблиц.

Импорт из SQL

Загрузите .sql-файл с INSERT-ами (например, экспорт из этого приложения). Файл выполняется построчно через PDO. Старые данные не удаляются.

Удалить все данные

Осторожно: действие необратимо. Перед удалением рекомендуется сделать экспорт (CSV или SQL).

Добавить

Редактировать счёт

Чтобы изменить только название или цвет, оставьте сумму без изменений.

Редактировать цель