51ee4ecb5a
Finished the planner end to end. Corrected rollover and yearly accounting, recurring targets, savings rules, and targeted dormant restorations. Added editing/deletion for income, expenses, targets, and categories. Rebuilt dashboard, analytics, calendar, settings, exports, authentication, and responsive mobile UI. Added original artwork at [financial-flow.png](/Users/tyemeclifford/Documents/GH/budget/public/assets/images/financial-flow.png), generated with built-in Imagegen using an abstract financial-momentum landscape prompt. Added [self-test.php](/Users/tyemeclifford/Documents/GH/budget/scripts/self-test.php). Verification passed: automated accounting/export tests, full browser workflow, responsive 390px layout, light/dark themes, and no console errors. Tested with FrankenPHP v1.12.4. Live Mailgun delivery was not attempted without credentials.
170 lines
6.0 KiB
PHP
170 lines
6.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Budget;
|
|
|
|
use PDO;
|
|
use PDOException;
|
|
use RuntimeException;
|
|
|
|
final class Database
|
|
{
|
|
private PDO $pdo;
|
|
private array $config;
|
|
private string $configPath;
|
|
|
|
public function __construct(string $configPath, string $rootPath)
|
|
{
|
|
$this->configPath = $configPath;
|
|
$this->config = $this->readConfig($configPath);
|
|
$sqliteOverride = getenv('BUDGET_SQLITE_PATH');
|
|
if (is_string($sqliteOverride) && $sqliteOverride !== '') {
|
|
$this->config['driver'] = 'sqlite';
|
|
$this->config['sqlite_path'] = $sqliteOverride;
|
|
}
|
|
$driver = (string) ($this->config['driver'] ?? 'sqlite');
|
|
|
|
if ($driver === 'mysql') {
|
|
$mysql = $this->config['mysql'] ?? [];
|
|
$dsn = sprintf(
|
|
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
|
$mysql['host'] ?? '127.0.0.1',
|
|
(int) ($mysql['port'] ?? 3306),
|
|
$mysql['database'] ?? 'budget_planner',
|
|
$mysql['charset'] ?? 'utf8mb4'
|
|
);
|
|
$this->pdo = new PDO($dsn, (string) ($mysql['username'] ?? ''), (string) ($mysql['password'] ?? ''), self::options());
|
|
} else {
|
|
$path = (string) ($this->config['sqlite_path'] ?? 'storage/database/budget.sqlite');
|
|
$absolutePath = str_starts_with($path, '/') ? $path : $rootPath . '/' . $path;
|
|
$directory = dirname($absolutePath);
|
|
if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
|
|
throw new RuntimeException('Unable to create SQLite storage directory.');
|
|
}
|
|
$this->pdo = new PDO('sqlite:' . $absolutePath, null, null, self::options());
|
|
$this->pdo->exec('PRAGMA foreign_keys = ON');
|
|
$this->pdo->exec('PRAGMA journal_mode = WAL');
|
|
}
|
|
|
|
$this->migrate();
|
|
}
|
|
|
|
public function pdo(): PDO
|
|
{
|
|
return $this->pdo;
|
|
}
|
|
|
|
public function driver(): string
|
|
{
|
|
return (string) ($this->config['driver'] ?? 'sqlite');
|
|
}
|
|
|
|
public function config(): array
|
|
{
|
|
return $this->config;
|
|
}
|
|
|
|
public function migrate(): void
|
|
{
|
|
foreach (Schema::statements($this->driver()) as $statement) {
|
|
$this->pdo->exec($statement);
|
|
}
|
|
}
|
|
|
|
public function migrateSqliteToMysql(array $mysqlConfig, string $rootPath): int
|
|
{
|
|
if ($this->driver() !== 'sqlite') {
|
|
throw new RuntimeException('Automatic migration can only start while SQLite mode is active.');
|
|
}
|
|
|
|
$dsn = sprintf(
|
|
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
|
$mysqlConfig['host'],
|
|
(int) $mysqlConfig['port'],
|
|
$mysqlConfig['database'],
|
|
$mysqlConfig['charset'] ?? 'utf8mb4'
|
|
);
|
|
$target = new PDO($dsn, (string) $mysqlConfig['username'], (string) $mysqlConfig['password'], self::options());
|
|
foreach (Schema::statements('mysql') as $statement) {
|
|
$target->exec($statement);
|
|
}
|
|
|
|
$tables = [
|
|
'settings', 'users', 'monthly_plans', 'income_entries', 'categories',
|
|
'budget_items', 'transactions', 'dead_account_entries', 'calendar_events',
|
|
'alert_logs', 'visits', 'audit_logs',
|
|
];
|
|
$copied = 0;
|
|
$target->beginTransaction();
|
|
try {
|
|
$target->exec('SET FOREIGN_KEY_CHECKS=0');
|
|
foreach ($tables as $table) {
|
|
$rows = $this->pdo->query('SELECT * FROM ' . $table)->fetchAll();
|
|
if ($rows === []) {
|
|
continue;
|
|
}
|
|
$columns = array_keys($rows[0]);
|
|
$quoted = array_map(static fn (string $column): string => '`' . $column . '`', $columns);
|
|
$placeholders = array_map(static fn (string $column): string => ':' . $column, $columns);
|
|
$sql = sprintf(
|
|
'REPLACE INTO `%s` (%s) VALUES (%s)',
|
|
$table,
|
|
implode(', ', $quoted),
|
|
implode(', ', $placeholders)
|
|
);
|
|
$insert = $target->prepare($sql);
|
|
foreach ($rows as $row) {
|
|
$insert->execute($row);
|
|
$copied++;
|
|
}
|
|
}
|
|
$target->exec('SET FOREIGN_KEY_CHECKS=1');
|
|
$target->commit();
|
|
} catch (\Throwable $exception) {
|
|
if ($target->inTransaction()) {
|
|
$target->rollBack();
|
|
}
|
|
throw $exception;
|
|
}
|
|
|
|
$newConfig = [
|
|
'driver' => 'mysql',
|
|
'sqlite_path' => $this->config['sqlite_path'] ?? 'storage/database/budget.sqlite',
|
|
'mysql' => [
|
|
'host' => $mysqlConfig['host'],
|
|
'port' => (int) $mysqlConfig['port'],
|
|
'database' => $mysqlConfig['database'],
|
|
'username' => $mysqlConfig['username'],
|
|
'password' => $mysqlConfig['password'],
|
|
'charset' => $mysqlConfig['charset'] ?? 'utf8mb4',
|
|
],
|
|
];
|
|
$encoded = json_encode($newConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
|
if ($encoded === false || file_put_contents($this->configPath, $encoded . PHP_EOL, LOCK_EX) === false) {
|
|
throw new RuntimeException('Data copied, but config/database.json could not be updated.');
|
|
}
|
|
|
|
return $copied;
|
|
}
|
|
|
|
private static function options(): array
|
|
{
|
|
return [
|
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|
PDO::ATTR_EMULATE_PREPARES => false,
|
|
];
|
|
}
|
|
|
|
private function readConfig(string $path): array
|
|
{
|
|
$raw = @file_get_contents($path);
|
|
$config = $raw === false ? null : json_decode($raw, true);
|
|
if (!is_array($config)) {
|
|
throw new RuntimeException('Database configuration is missing or invalid.');
|
|
}
|
|
return $config;
|
|
}
|
|
}
|