initial symfony commit

This commit is contained in:
ayman
2025-10-04 12:34:59 +01:00
parent 43b1a34317
commit 75198f96e7
64 changed files with 1587 additions and 1392 deletions

2
.gitignore vendored
View File

@@ -1,2 +0,0 @@
.idea
.idea/*

View File

@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace App;
use App\Lib\Database;
use App\Repositories\ExpenseRepository;
use App\Services\ExpenseService;
final class Container
{
/** @var array<string,mixed> */
private array $params;
/** @var array<string,mixed> */
private array $singletons = [];
/**
* @param array<string,mixed> $params
*/
public function __construct(array $params = [])
{
$this->params = $params;
}
public function getDatabase(): Database
{
return $this->singletons[Database::class]
??= new Database(
(string)$this->params['db.dsn'],
(string)$this->params['db.user'],
(string)$this->params['db.pass']
);
}
public function getExpenseRepository(): ExpenseRepository
{
return $this->singletons[ExpenseRepository::class]
??= new ExpenseRepository($this->getDatabase());
}
public function getExpenseService(): ExpenseService
{
return $this->singletons[ExpenseService::class]
??= new ExpenseService($this->getExpenseRepository());
}
}

View File

@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
// DB config and core
require_once __DIR__ . '/DatabaseConnectionConfig.php';
require_once __DIR__ . '/container.php';
require_once __DIR__ . '/DatabaseConnectionHandler.php';
// Domain repos/services
require_once __DIR__ . '/ExpenseRepository.php';
require_once __DIR__ . '/ExpenseService.php';
// Auth
require_once __DIR__ . '/auth_repository.php';
require_once __DIR__ . '/auth_service.php';
// JSON responder
require_once __DIR__ . '/JsonResponseHandler.php';
use App\Container;
function container(): Container {
static $c = null;
if ($c === null) {
$c = new Container([
'db.dsn' => DB_DSN,
'db.user' => DB_USER,
'db.pass' => DB_PASS,
]);
}
return $c;
}

View File

@@ -1,290 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ContainerFactory.php';
use App\Services\ExpenseService;
$service = container()->getExpenseService();
$todayExpenses = $service->listByDate(new DateTimeImmutable('today'));
$today = (new DateTimeImmutable('today'))->format('Y-m-d');
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Daily Expenses</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Flatpickr CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css">
<style>
/* Highlight dates with expenses in the calendar */
.flatpickr-day.has-expense {
background: #d1e7dd !important;
border-color: #198754 !important;
font-weight: bold;
color: #0f5132 !important;
}
.flatpickr-day.has-expense:hover {
background: #a3cfbb !important;
}
.flatpickr-day.has-expense.selected {
background: #198754 !important;
color: white !important;
}
</style>
</head>
<body class="bg-light">
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="mb-0">Daily Expenses</h1>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary btn-sm" href="LogoutRedirect.php">Logout</a>
<a class="btn btn-outline-primary btn-sm" href="index.php?action=download_csv">Download CSV</a>
<a class="btn btn-outline-success btn-sm" href="manage_types_page.php">Manage Types</a>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<form id="expense-form" novalidate>
<div class="row g-3">
<div class="col-sm-4">
<label for="amount" class="form-label">Amount</label>
<div class="input-group">
<span class="input-group-text">JD</span>
<input type="number" step="0.01" min="0" class="form-control" id="amount" name="amount" required>
</div>
<div class="invalid-feedback">Please provide a valid amount.</div>
</div>
<div class="col-sm-4">
<label for="type" class="form-label">Type</label>
<select class="form-select" id="type" name="type" required>
<option value="" selected disabled>Choose...</option>
</select>
<div class="invalid-feedback">Please select a type.</div>
</div>
<div class="col-sm-4">
<label for="note" class="form-label">Note (optional)</label>
<input type="text" maxlength="255" class="form-control" id="note" name="note" placeholder="e.g., Coffee">
</div>
<div class="col-sm-4">
<label for="spent_at" class="form-label">Date</label>
<input type="date" class="form-control" id="spent_at" name="spent_at" value="<?= $today ?>" required>
<div class="invalid-feedback">Please choose a date.</div>
</div>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary">Add Expense</button>
</div>
</form>
<div id="alert-placeholder" class="mt-3"></div>
</div>
</div>
<div class="card">
<div class="card-header d-flex flex-wrap gap-2 justify-content-between align-items-center">
<span>Expenses</span>
<div class="d-flex align-items-center gap-2">
<label for="filter-date" class="col-form-label">View date:</label>
<input type="text" class="form-control" id="filter-date" placeholder="Select date" style="min-width: 150px;" />
</div>
<small class="text-muted" id="today-date-label"><?= $today ?></small>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped table-hover mb-0" id="expenses-table">
<thead class="table-light">
<tr>
<th style="width: 120px;">Amount</th>
<th style="width: 180px;">Type</th>
<th>Note</th>
<th style="width: 130px;">Date</th>
</tr>
</thead>
<tbody>
<?php if (empty($todayExpenses)): ?>
<tr><td colspan="4" class="text-center text-muted py-4">No expenses yet.</td></tr>
<?php else: ?>
<?php foreach ($todayExpenses as $e): ?>
<tr>
<td>JD<?= htmlspecialchars(number_format((float)$e['amount'], 2)) ?></td>
<td><?= htmlspecialchars($e['type']) ?></td>
<td><?= htmlspecialchars($e['note'] ?? '') ?></td>
<td><?= htmlspecialchars($e['spent_at']) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- Flatpickr JS -->
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js"></script>
<script type="text/javascript">
(function ($) {
var datesWithData = [];
var filterPicker;
function showAlert(type, message) {
$('#alert-placeholder').html(
'<div class="alert alert-' + type + ' alert-dismissible" role="alert">' +
message +
'<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>' +
'</div>'
);
}
function renderRows(items) {
var $tbody = $('#expenses-table tbody');
if (!items || !items.length) {
$tbody.html('<tr><td colspan="4" class="text-center text-muted py-4">No expenses yet.</td></tr>');
return;
}
var html = items.map(function (e) {
return '<tr>'
+ '<td>JD' + parseFloat(e.amount).toFixed(2) + '</td>'
+ '<td>' + $('<div>').text(e.type).html() + '</td>'
+ '<td>' + $('<div>').text(e.note || '').html() + '</td>'
+ '<td>' + $('<div>').text(e.spent_at).html() + '</td>'
+ '</tr>';
}).join('');
$tbody.html(html);
}
function loadTypes() {
var $type = $('#type');
$type.prop('disabled', true);
$.getJSON('index.php?action=types').done(function (res) {
if (!res.success) { showAlert('danger', res.error || 'Failed to load types.'); return; }
var opts = ['<option value="" selected disabled>Choose...</option>'];
(res.data.types || []).forEach(function (t) {
var safe = $('<div>').text(t).html();
opts.push('<option value="' + safe + '">' + safe + '</option>');
});
$type.html(opts.join(''));
}).fail(function () {
showAlert('danger', 'Failed to load types.');
}).always(function () {
$type.prop('disabled', false);
});
}
function loadDatesWithExpenses(callback) {
$.getJSON('index.php?action=dates_with_expenses').done(function(res){
if (res.success) {
datesWithData = res.data.dates || [];
console.log('Loaded dates with expenses:', datesWithData);
if (callback) callback();
}
}).fail(function(){
console.error('Failed to load dates');
});
}
function formatDateLocal(dateObj) {
// Format as YYYY-MM-DD in local time, not UTC
var year = dateObj.getFullYear();
var month = String(dateObj.getMonth() + 1).padStart(2, '0');
var day = String(dateObj.getDate()).padStart(2, '0');
return year + '-' + month + '-' + day;
}
function initFilterPicker() {
if (filterPicker) {
filterPicker.destroy();
}
filterPicker = flatpickr('#filter-date', {
dateFormat: 'Y-m-d',
defaultDate: '<?= $today ?>',
onDayCreate: function(dObj, dStr, fp, dayElem) {
// Use local date formatting to avoid timezone shift
var dateStr = formatDateLocal(dayElem.dateObj);
if (datesWithData.indexOf(dateStr) !== -1) {
dayElem.classList.add('has-expense');
console.log('Highlighting:', dateStr);
}
},
onChange: function(selectedDates, dateStr) {
if (dateStr) {
loadByDate(dateStr);
}
}
});
}
function loadByDate(dateStr) {
$.getJSON('index.php?action=list_by_date&date=' + encodeURIComponent(dateStr))
.done(function (res) {
if (!res.success) { showAlert('danger', res.error || 'Failed to load.'); return; }
$('#today-date-label').text(res.data.date);
renderRows(res.data.items || []);
})
.fail(function () { showAlert('danger', 'Failed to load.'); });
}
$(function () {
loadTypes();
// Load dates with expenses, then init picker, then load today's data
loadDatesWithExpenses(function(){
initFilterPicker();
loadByDate('<?= $today ?>');
});
});
$('#expense-form').on('submit', function (e) {
e.preventDefault();
var form = this;
form.classList.add('was-validated');
if (!form.checkValidity()) return;
var payload = {
amount: $('#amount').val(),
type: $('#type').val(),
note: $('#note').val(),
spent_at: $('#spent_at').val()
};
$.ajax({
url: 'index.php?action=create',
method: 'POST',
data: JSON.stringify(payload),
contentType: 'application/json',
dataType: 'json'
}).done(function (res) {
if (res.success) {
showAlert('success', 'Expense added.');
// Reload dates and reinit picker to show new highlights
loadDatesWithExpenses(function(){
initFilterPicker();
var d = $('#filter-date').val() || payload.spent_at;
filterPicker.setDate(d, false); // don't trigger onChange
loadByDate(d);
});
form.reset();
form.classList.remove('was-validated');
$('#spent_at').val('<?= $today ?>');
} else {
showAlert('danger', res.error || 'Failed to add expense.');
}
}).fail(function (xhr) {
var msg = 'Request failed.';
if (xhr.responseJSON && xhr.responseJSON.error) msg = xhr.responseJSON.error;
showAlert('danger', msg);
});
});
})(jQuery);
</script>
</body>
</html>

View File

@@ -1,7 +0,0 @@
<?php
declare(strict_types=1);
// Update these for your environment
const DB_DSN = 'mysql:host=localhost;port=3306;dbname=expenses;charset=utf8mb4';
const DB_USER = 'expensesuser';
const DB_PASS = 't5DDX**L*jTLMs%*';

View File

@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Lib;
use PDO;
use PDOException;
use PDOStatement;
final class Database
{
private PDO $pdo;
public function __construct(string $dsn, string $user, string $pass)
{
$opts = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
$this->pdo = new PDO($dsn, $user, $pass, $opts);
}
public function pdo(): PDO
{
return $this->pdo;
}
/**
* @param array<int|string,mixed> $params
*/
public function run(string $sql, array $params = []): PDOStatement
{
$stmt = $this->pdo->prepare($sql);
foreach ($params as $k => $v) {
$stmt->bindValue(is_int($k) ? $k + 1 : (string)$k, $v);
}
$stmt->execute();
return $stmt;
}
}

View File

@@ -1,100 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ContainerFactory.php';
use App\Http\JsonResponder;
use App\Services\ExpenseService;
header('Content-Type: application/json; charset=utf-8');
try {
$action = $_GET['action'] ?? '';
$responder = new JsonResponder();
$service = container()->getExpenseService();
// Fetch expenses by date: GET index.php?action=list_by_date&date=YYYY-MM-DD
if ($action === 'list_by_date') {
$date = isset($_GET['date']) ? (string)$_GET['date'] : '';
$dt = DateTimeImmutable::createFromFormat('Y-m-d', $date) ?: new DateTimeImmutable('today');
$items = $service->listByDate($dt);
$responder->success(['items' => $items, 'date' => $dt->format('Y-m-d')]);
exit;
}
if ($action === 'create') {
$input = json_decode(file_get_contents('php://input') ?: '[]', true, 512, JSON_THROW_ON_ERROR);
$amount = isset($input['amount']) ? (string)$input['amount'] : '';
$type = isset($input['type']) ? trim((string)$input['type']) : '';
$note = isset($input['note']) ? trim((string)$input['note']) : null;
$spentAt = isset($input['spent_at']) ? trim((string)$input['spent_at']) : '';
$expense = $service->create($amount, $type, $note, $spentAt);
$responder->success([
'amount' => $expense['amount'],
'type' => $expense['type'],
'note' => $expense['note'],
'spent_at' => $expense['spent_at'],
]);
exit;
}
if ($action === 'types') {
$types = $service->listTypes();
$responder->success(['types' => $types]);
exit;
}
if ($action === 'add_type' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$input = json_decode(file_get_contents('php://input') ?: '[]', true, 512, JSON_THROW_ON_ERROR);
$name = isset($input['name']) ? (string)$input['name'] : '';
$service->addType($name);
$responder->success(['message' => 'Type added']);
exit;
}
if ($action === 'list_by_date') {
$date = isset($_GET['date']) ? (string)$_GET['date'] : '';
$dt = DateTimeImmutable::createFromFormat('Y-m-d', $date) ?: new DateTimeImmutable('today');
$items = $service->listByDate($dt);
$responder->success(['items' => $items, 'date' => $dt->format('Y-m-d')]);
exit;
}
// New: return dates with expenses
if ($action === 'dates_with_expenses') {
$dates = $service->getDistinctDates();
$responder->success(['dates' => $dates]);
exit;
}
if ($action === 'download_csv') {
$all = $service->listAll();
header_remove('Content-Type');
header('Content-Type: text/csv; charset=utf-8');
$ts = (new DateTimeImmutable('now'))->format('Ymd_His');
header('Content-Disposition: attachment; filename="expenses_' . $ts . '.csv"');
$out = fopen('php://output', 'w');
fputcsv($out, ['ID', 'Amount', 'Type', 'Note', 'Spent At', 'Created At']);
foreach ($all as $row) {
fputcsv($out, [
$row['id'],
number_format((float)$row['amount'], 2, '.', ''),
$row['type'],
$row['note'] ?? '',
$row['spent_at'],
$row['created_at'],
]);
}
fclose($out);
exit;
}
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Unknown action']);
} catch (Throwable $e) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}

View File

@@ -1,83 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Lib\Database;
use DateTimeImmutable;
final class ExpenseRepository
{
public function __construct(private Database $db) {}
/**
* @param array{amount:string,type_id:int,note:?(string),spent_at:string} $data
*/
public function insert(array $data): void
{
$sql = 'INSERT INTO expenses (amount, type_id, note, spent_at) VALUES (:amount, :type_id, :note, :spent_at)';
$this->db->run($sql, [
':amount' => $data['amount'],
':type_id' => $data['type_id'],
':note' => $data['note'],
':spent_at' => $data['spent_at'],
]);
}
/**
* @return array<int,array<string,mixed>>
*/
public function listByDate(DateTimeImmutable $date): array
{
$sql = 'SELECT e.id, e.amount, et.name AS type, e.note, e.spent_at
FROM expenses e
INNER JOIN expense_types et ON e.type_id = et.id
WHERE e.spent_at = :d
ORDER BY e.id DESC';
$stmt = $this->db->run($sql, [':d' => $date->format('Y-m-d')]);
return $stmt->fetchAll();
}
/**
* @return array<int,array{id:int,name:string}>
*/
public function listTypes(): array
{
$stmt = $this->db->run('SELECT id, name FROM expense_types ORDER BY name ASC');
return $stmt->fetchAll();
}
/**
* @return array<int,array{id:int,amount:string,type:string,note:?(string),spent_at:string,created_at:string}>
*/
public function listAll(): array
{
$sql = 'SELECT e.id, e.amount, et.name AS type, e.note, e.spent_at, e.created_at
FROM expenses e
INNER JOIN expense_types et ON e.type_id = et.id
ORDER BY e.spent_at DESC, e.id DESC';
$stmt = $this->db->run($sql);
return $stmt->fetchAll();
}
public function addType(string $name): void
{
$this->db->run('INSERT INTO expense_types (name) VALUES (:n)', [':n' => $name]);
}
public function findTypeIdByName(string $name): ?int
{
$stmt = $this->db->run('SELECT id FROM expense_types WHERE name = :n LIMIT 1', [':n' => $name]);
$row = $stmt->fetch();
return $row ? (int)$row['id'] : null;
}
/**
* @return array<int,string> Array of YYYY-MM-DD strings
*/
public function getDistinctDates(): array
{
$stmt = $this->db->run('SELECT DISTINCT spent_at FROM expenses ORDER BY spent_at DESC');
return array_column($stmt->fetchAll(), 'spent_at');
}
}

View File

@@ -1,113 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Repositories\ExpenseRepository;
use DateTimeImmutable;
use InvalidArgumentException;
final class ExpenseService
{
public function __construct(private ExpenseRepository $repo) {}
/**
* @return array{amount:string,type:string,note:?(string),spent_at:string}
*/
public function create(string $amount, string $typeName, ?string $note, string $spentAt): array
{
$amount = trim($amount);
$typeName = trim($typeName);
$note = $note !== null ? trim($note) : null;
$spentAt = trim($spentAt);
if ($amount === '' || !is_numeric($amount) || (float)$amount < 0) {
throw new InvalidArgumentException('Amount must be a non-negative number.');
}
if ($typeName === '') {
throw new InvalidArgumentException('Type is required.');
}
if ($note !== null && mb_strlen($note) > 255) {
throw new InvalidArgumentException('Note must be at most 255 characters.');
}
$date = DateTimeImmutable::createFromFormat('Y-m-d', $spentAt);
if (!$date || $date->format('Y-m-d') !== $spentAt) {
throw new InvalidArgumentException('Invalid date format. Use YYYY-MM-DD.');
}
// Find type ID by name
$typeId = $this->repo->findTypeIdByName($typeName);
if ($typeId === null) {
throw new InvalidArgumentException('Invalid type.');
}
$payload = [
'amount' => number_format((float)$amount, 2, '.', ''),
'type_id' => $typeId,
'note' => $note !== '' ? $note : null,
'spent_at' => $spentAt,
];
$this->repo->insert($payload);
return [
'amount' => $payload['amount'],
'type' => $typeName,
'note' => $payload['note'],
'spent_at' => $payload['spent_at'],
];
}
/**
* @return array<int,array<string,mixed>>
*/
public function listByDate(DateTimeImmutable $date): array
{
return $this->repo->listByDate($date);
}
// New: expose types
/**
* @return array<int,string>
*/
public function listTypes(): array
{
return array_map(static fn($t) => $t['name'], $this->repo->listTypes());
}
// New: list all for CSV
/**
* @return array<int,array{id:int,amount:string,type:string,note:?(string),spent_at:string,created_at:string}>
*/
public function listAll(): array
{
return $this->repo->listAll();
}
// New: add type
public function addType(string $name): void
{
$name = trim($name);
if ($name === '') {
throw new InvalidArgumentException('Type name is required.');
}
if (mb_strlen($name) > 100) {
throw new InvalidArgumentException('Type name must be at most 100 characters.');
}
// prevent duplicates (case-insensitive)
$existing = array_map(static fn($t) => mb_strtolower($t['name']), $this->repo->listTypes());
if (in_array(mb_strtolower($name), $existing, true)) {
throw new InvalidArgumentException('Type already exists.');
}
$this->repo->addType($name);
}
// New: dates with expenses
/**
* @return array<int,string>
*/
public function getDistinctDates(): array
{
return $this->repo->getDistinctDates();
}
}

View File

@@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http;
final class JsonResponder
{
/**
* @param array<string,mixed> $data
*/
public function success(array $data): void
{
echo json_encode(['success' => true, 'data' => $data], JSON_UNESCAPED_UNICODE);
}
}

View File

@@ -1,7 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ContainerFactory.php';
container()->getAuthService()->logout();
header('Location: login_page.php');
exit;

View File

@@ -1,48 +0,0 @@
# Expenses App (Symfony 6.4)
This project has been migrated to use the Symfony 6.4 framework while preserving the original domain logic.
The new Symfony application structure has been placed under the `symfony/` folder at the project root to keep it separated from legacy files.
## Requirements
- PHP >= 8.1
- Composer
- Database with PDO (MySQL/PostgreSQL/etc.)
## Setup
1. Copy `.env` variables (or set environment variables in your server):
- APP_ENV=dev
- APP_DEBUG=1
- APP_SECRET=your-secret-here
- DB_DSN=your-pdo-dsn (e.g., mysql:host=127.0.0.1;dbname=expenses;charset=utf8mb4)
- DB_USER=your-db-user
- DB_PASS=your-db-pass
2. Install dependencies (run inside the Symfony folder):
- cd symfony
- composer install
3. Run the built-in server (still inside the `symfony` folder):
- php -S localhost:8000 -t public
4. Open the app:
- http://localhost:8000
## Entry point
- New Symfony entry point is `symfony/public/index.php`.
## Routes (Symfony)
- GET / → Daily expenses page (Twig)
- GET /login → Login page
- POST /login → Perform login
- POST /logout → Logout and redirect to /login
- GET /expenses/types → JSON list of types
- POST /expenses/types/add → JSON add a type
- POST /expenses/create → JSON create an expense
- GET /expenses/by-date/{date} → JSON list by date (YYYY-MM-DD)
- GET /expenses/dates → JSON list of dates with expenses
- GET /expenses/download.csv → Download CSV of expenses
## Legacy files
Legacy PHP files have been left in place at the project root for reference. The existing domain classes (repositories, services) continue to be used and are wired via Symfony's DI.
## Database schema
See `expenses_schema.sql` for the table structure.

View File

@@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Lib\Database;
final class AuthRepository
{
public function __construct(private Database $db) {}
public function findByEmail(string $email): ?array
{
$stmt = $this->db->run('SELECT id, email, password_hash FROM users WHERE email = :e LIMIT 1', [':e' => $email]);
$row = $stmt->fetch();
return $row ?: null;
}
}

View File

@@ -1,62 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Repositories\AuthRepository;
use InvalidArgumentException;
final class AuthService
{
public function __construct(private AuthRepository $repo) {}
public function login(string $email, string $password): void
{
$email = trim(mb_strtolower($email));
if ($email === '' || $password === '') {
throw new InvalidArgumentException('Email and password are required.');
}
$user = $this->repo->findByEmail($email);
if (!$user || !password_verify($password, $user['password_hash'])) {
throw new InvalidArgumentException('Invalid credentials.');
}
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start([
'cookie_httponly' => true,
'cookie_secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'cookie_samesite' => 'Lax',
]);
}
session_regenerate_id(true);
$_SESSION['uid'] = (int)$user['id'];
$_SESSION['email'] = $user['email'];
}
public function logout(): void
{
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
}
public function ensureAuthenticated(): void
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start([
'cookie_httponly' => true,
'cookie_secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'cookie_samesite' => 'Lax',
]);
}
if (empty($_SESSION['uid'])) {
header('Location: login_page.php');
http_response_code(302);
exit;
}
}
}

21
bin/console Normal file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env php
<?php
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
if (!is_dir(dirname(__DIR__).'/vendor')) {
throw new LogicException('Dependencies are missing. Try running "composer install".');
}
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
}
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
return new Application($kernel);
};

8
config/bundles.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
];

View File

@@ -0,0 +1,19 @@
framework:
cache:
# Unique name of your app: used to compute stable namespaces for cache keys.
#prefix_seed: your_vendor_name/app_name
# The "app" cache stores to the filesystem by default.
# The data in this cache should persist between deploys.
# Other options include:
# Redis
#app: cache.adapter.redis
#default_redis_provider: redis://localhost
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu
# Namespaced pools use the above "app" backend by default
#pools:
#my.dedicated.cache: null

View File

@@ -0,0 +1,48 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '15'
profiling_collect_backtrace: '%kernel.debug%'
orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
report_fields_where_declared: true
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true
mappings:
App:
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod:
doctrine:
orm:
auto_generate_proxy_classes: false
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool
framework:
cache:
pools:
doctrine.result_cache_pool:
adapter: cache.app
doctrine.system_cache_pool:
adapter: cache.system

View File

@@ -0,0 +1,4 @@
doctrine_migrations:
migrations_paths:
'DoctrineMigrations': '%kernel.project_dir%/migrations'
enable_profiler: false

View File

@@ -0,0 +1,21 @@
framework:
secret: '%env(APP_SECRET)%'
http_method_override: false
handle_all_throwables: true
session:
handler_id: null
cookie_secure: auto
cookie_samesite: lax
storage_factory_id: session.storage.factory.native
name: EXPENSES_SESSID
router:
utf8: true
csrf_protection: true
php_errors:
log: true
when@test:
framework:
test: true
session:
storage_factory_id: session.storage.factory.mock_file

View File

@@ -0,0 +1,12 @@
framework:
router:
utf8: true
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
#default_uri: http://localhost
when@prod:
framework:
router:
strict_requirements: null

View File

@@ -0,0 +1,32 @@
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
providers:
app_user_provider:
id: App\Security\UserProvider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_user_provider
custom_authenticators:
- App\Security\LoginFormAuthenticator
entry_point: App\Security\LoginFormAuthenticator
logout:
path: logout
target: login
remember_me:
secret: '%kernel.secret%'
lifetime: 604800
path: /
always_remember_me: true
access_control:
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/register, roles: PUBLIC_ACCESS }
- { path: ^/, roles: IS_AUTHENTICATED_FULLY }

5
config/preload.php Normal file
View File

@@ -0,0 +1,5 @@
<?php
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
}

View File

@@ -35,6 +35,33 @@ expenses_dates_with_expenses:
controller: App\Controller\ExpenseController::datesWithExpenses
methods: [GET]
# Expense Types Management
manage_types:
path: /types/manage
controller: App\Controller\ExpenseTypeController::manage
methods: [GET]
types_list:
path: /types/list
controller: App\Controller\ExpenseTypeController::list
methods: [GET]
types_add:
path: /types/add
controller: App\Controller\ExpenseTypeController::add
methods: [POST]
types_delete:
path: /types/delete/{id}
controller: App\Controller\ExpenseTypeController::delete
requirements:
id: '\d+'
methods: [DELETE]
register:
path: /register
controller: App\Controller\RegistrationController::register
login:
path: /login
controller: App\Controller\AuthController::login
@@ -42,4 +69,3 @@ login:
logout:
path: /logout
controller: App\Controller\AuthController::logout
methods: [POST]

View File

@@ -0,0 +1,4 @@
when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
prefix: /_error

31
config/services.yaml Normal file
View File

@@ -0,0 +1,31 @@
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
# Make controllers public
App\Controller\:
resource: '../src/Controller/'
tags: ['controller.service_arguments']
public: true
# Security services
App\Security\UserProvider:
autowire: true
App\Security\LoginFormAuthenticator:
autowire: true

View File

@@ -1,61 +0,0 @@
<?php
declare(strict_types=1);
namespace App;
use App\Lib\Database;
use App\Repositories\ExpenseRepository;
use App\Repositories\AuthRepository;
use App\Services\ExpenseService;
use App\Services\AuthService;
final class Container
{
/** @var array<string,mixed> */
private array $params;
/** @var array<string,mixed> */
private array $singletons = [];
/**
* @param array<string,mixed> $params
*/
public function __construct(array $params = [])
{
$this->params = $params;
}
public function getDatabase(): Database
{
return $this->singletons[Database::class]
??= new Database(
(string)$this->params['db.dsn'],
(string)$this->params['db.user'],
(string)$this->params['db.pass']
);
}
public function getExpenseRepository(): ExpenseRepository
{
return $this->singletons[ExpenseRepository::class]
??= new ExpenseRepository($this->getDatabase());
}
public function getExpenseService(): ExpenseService
{
return $this->singletons[ExpenseService::class]
??= new ExpenseService($this->getExpenseRepository());
}
public function getAuthRepository(): AuthRepository
{
return $this->singletons[AuthRepository::class]
??= new AuthRepository($this->getDatabase());
}
public function getAuthService(): AuthService
{
return $this->singletons[AuthService::class]
??= new AuthService($this->getAuthRepository());
}
}

View File

@@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
// ... existing code ...
require_once __DIR__ . '/AuthRepository.php';
require_once __DIR__ . '/AuthService.php';
// ... existing code ...
use App\Container;
use App\Repositories\AuthRepository;
use App\Services\AuthService;
// ... existing code ...
function container(): Container {
static $c = null;
if ($c === null) {
$c = new Container([
'db.dsn' => DB_DSN,
'db.user' => DB_USER,
'db.pass' => DB_PASS,
]);
}
return $c;
}
// Add these helper getters on the Container if not present (or use closures here)
if (!method_exists(Container::class, 'getAuthRepository')) {
class_alias(\App\Container::class, 'App_Container_With_Auth');
}

View File

@@ -1,51 +0,0 @@
CREATE TABLE expense_types (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE
) ENGINE=InnoDB;
INSERT INTO expense_types (name) VALUES
('Food'), ('Transport'), ('Groceries'), ('Utilities'), ('Entertainment'), ('Other');
CREATE TABLE expenses (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
amount DECIMAL(10,2) NOT NULL,
type_id INT UNSIGNED NOT NULL,
note VARCHAR(255) NULL,
spent_at DATE NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (type_id) REFERENCES expense_types(id) ON DELETE RESTRICT ON UPDATE CASCADE,
INDEX idx_spent_at (spent_at)
) ENGINE=InnoDB;
-- Add type_id column
ALTER TABLE expenses ADD COLUMN type_id INT UNSIGNED NULL AFTER amount;
-- Migrate existing string types to IDs (assuming types exist in expense_types)
UPDATE expenses e
INNER JOIN expense_types et ON e.type = et.name
SET e.type_id = et.id;
-- Make type_id NOT NULL after migration
ALTER TABLE expenses MODIFY type_id INT UNSIGNED NOT NULL;
-- Add foreign key constraint
ALTER TABLE expenses ADD CONSTRAINT fk_expenses_type
FOREIGN KEY (type_id) REFERENCES expense_types(id)
ON DELETE RESTRICT ON UPDATE CASCADE;
-- Drop old type column
ALTER TABLE expenses DROP COLUMN type;
-- Add index on spent_at for performance
ALTER TABLE expenses ADD INDEX idx_spent_at (spent_at);
CREATE TABLE users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(190) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
-- Seed an initial user (replace with your email; the hash is for password: ChangeMe!2024)
INSERT INTO users (email, password_hash) VALUES
('admin@example.com', '$2y$10$5E3o0b0iPjW7mQ6b3mV6GukQw8g6n8a8oFJmTgVZk3e7nqzM4TtG6');

View File

@@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ContainerFactory.php';
// Require login for any access
container()->getAuthService()->ensureAuthenticated();
$action = $_GET['action'] ?? '';
if (in_array($action, ['create','types','add_type','download_csv','list_by_date','dates_with_expenses'], true)) {
require __DIR__ . '/ExpenseActionHandler.php';
exit;
}
require __DIR__ . '/DailyExpensesPage.php';

View File

@@ -1,49 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ContainerFactory.php';
use App\Services\AuthService;
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
/** @var AuthService $auth */
$auth = container()->getAuthService();
$auth->login($_POST['email'] ?? '', $_POST['password'] ?? '');
header('Location: index.php');
exit;
} catch (Throwable $e) {
$error = $e->getMessage();
}
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Login</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container py-5" style="max-width: 420px;">
<h1 class="h3 mb-3">Sign in</h1>
<?php if ($error): ?>
<div class="alert alert-danger"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="post" novalidate>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button class="btn btn-primary w-100" type="submit">Sign in</button>
</form>
</div>
</body>
</html>

View File

@@ -1,98 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ContainerFactory.php';
container()->getAuthService()->ensureAuthenticated();
$types = container()->getExpenseService()->listTypes();
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Manage Types</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container py-4" style="max-width: 640px;">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h4 mb-0">Manage Types</h1>
<a class="btn btn-outline-secondary btn-sm" href="index.php">Back</a>
</div>
<div class="card mb-3">
<div class="card-body">
<form id="add-type-form" class="row g-2" novalidate>
<div class="col-8">
<label for="type_name" class="form-label">New Type</label>
<input type="text" maxlength="100" class="form-control" id="type_name" name="type_name" required>
<div class="invalid-feedback">Enter a type name (max 100 chars).</div>
</div>
<div class="col-4 d-flex align-items-end">
<button type="submit" class="btn btn-success w-100">Add</button>
</div>
</form>
</div>
</div>
<div id="alert-placeholder"></div>
<div class="card">
<div class="card-header">Existing Types</div>
<ul class="list-group list-group-flush" id="types-list">
<?php foreach ($types as $t): ?>
<li class="list-group-item"><?= htmlspecialchars($t) ?></li>
<?php endforeach; ?>
<?php if (empty($types)): ?>
<li class="list-group-item text-muted">No types yet.</li>
<?php endif; ?>
</ul>
</div>
</div>
<script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
<script>
(function($){
function showAlert(type, message) {
$('#alert-placeholder').html(
'<div class="alert alert-' + type + ' alert-dismissible mt-3" role="alert">' +
message +
'<button type="button" class="btn-close" data-bs-dismiss="alert"></button>' +
'</div>'
);
}
$('#add-type-form').on('submit', function(e){
e.preventDefault();
var form = this;
form.classList.add('was-validated');
if (!form.checkValidity()) return;
var name = $('#type_name').val();
$.ajax({
url: 'index.php?action=add_type',
method: 'POST',
data: JSON.stringify({ name: name }),
contentType: 'application/json',
dataType: 'json'
}).done(function(res){
if (res.success) {
showAlert('success', 'Type added.');
$('#types-list').prepend('<li class="list-group-item">' + $('<div>').text(name).html() + '</li>');
form.reset();
form.classList.remove('was-validated');
} else {
showAlert('danger', res.error || 'Failed to add type.');
}
}).fail(function(xhr){
var msg = 'Request failed.';
if (xhr.responseJSON && xhr.responseJSON.error) msg = xhr.responseJSON.error;
showAlert('danger', msg);
});
});
})(jQuery);
</script>
</body>
</html>

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251004005440 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE expenses DROP FOREIGN KEY fk_expenses_type');
$this->addSql('DROP TABLE expense_types');
$this->addSql('DROP TABLE expenses');
$this->addSql('ALTER TABLE users ADD roles JSON NOT NULL COMMENT \'(DC2Type:json)\', CHANGE id id INT AUTO_INCREMENT NOT NULL, CHANGE email email VARCHAR(180) NOT NULL, CHANGE created_at created_at DATETIME NOT NULL, CHANGE password_hash password VARCHAR(255) NOT NULL');
$this->addSql('ALTER TABLE users RENAME INDEX email TO UNIQ_1483A5E9E7927C74');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE expense_types (id INT UNSIGNED AUTO_INCREMENT NOT NULL, name VARCHAR(100) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_general_ci`, UNIQUE INDEX name (name), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_general_ci` ENGINE = InnoDB COMMENT = \'\' ');
$this->addSql('CREATE TABLE expenses (id INT UNSIGNED AUTO_INCREMENT NOT NULL, type_id INT UNSIGNED NOT NULL, amount NUMERIC(10, 2) NOT NULL, note VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_general_ci`, spent_at DATE NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, INDEX fk_expenses_type (type_id), INDEX idx_spent_at (spent_at), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_general_ci` ENGINE = InnoDB COMMENT = \'\' ');
$this->addSql('ALTER TABLE expenses ADD CONSTRAINT fk_expenses_type FOREIGN KEY (type_id) REFERENCES expense_types (id) ON UPDATE CASCADE');
$this->addSql('ALTER TABLE users DROP roles, CHANGE id id INT UNSIGNED AUTO_INCREMENT NOT NULL, CHANGE email email VARCHAR(190) NOT NULL, CHANGE created_at created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CHANGE password password_hash VARCHAR(255) NOT NULL');
$this->addSql('ALTER TABLE users RENAME INDEX uniq_1483a5e9e7927c74 TO email');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251004005722 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE users (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL COMMENT \'(DC2Type:json)\', password VARCHAR(255) NOT NULL, created_at DATETIME NOT NULL, UNIQUE INDEX UNIQ_1483A5E9E7927C74 (email), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE users');
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251004011934 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE expense_types (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, name VARCHAR(100) NOT NULL, created_at DATETIME NOT NULL, INDEX IDX_FA382E0AA76ED395 (user_id), UNIQUE INDEX unique_user_type (user_id, name), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE expenses (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, type_id INT NOT NULL, amount NUMERIC(10, 2) NOT NULL, note VARCHAR(255) DEFAULT NULL, spent_at DATE NOT NULL, created_at DATETIME NOT NULL, INDEX IDX_2496F35BC54C8C93 (type_id), INDEX idx_spent_at (spent_at), INDEX idx_user_id (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE expense_types ADD CONSTRAINT FK_FA382E0AA76ED395 FOREIGN KEY (user_id) REFERENCES users (id)');
$this->addSql('ALTER TABLE expenses ADD CONSTRAINT FK_2496F35BA76ED395 FOREIGN KEY (user_id) REFERENCES users (id)');
$this->addSql('ALTER TABLE expenses ADD CONSTRAINT FK_2496F35BC54C8C93 FOREIGN KEY (type_id) REFERENCES expense_types (id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE expense_types DROP FOREIGN KEY FK_FA382E0AA76ED395');
$this->addSql('ALTER TABLE expenses DROP FOREIGN KEY FK_2496F35BA76ED395');
$this->addSql('ALTER TABLE expenses DROP FOREIGN KEY FK_2496F35BC54C8C93');
$this->addSql('DROP TABLE expense_types');
$this->addSql('DROP TABLE expenses');
}
}

7
public/.htaccess Normal file
View File

@@ -0,0 +1,7 @@
<IfModule mod_rewrite.c>
RewriteEngine On
# If the requested filename exists, serve it directly
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ /index.php [QSA,L]
</IfModule>

37
public/index.php Normal file
View File

@@ -0,0 +1,37 @@
<?php
use App\Kernel;
use Symfony\Component\Dotenv\Dotenv;
use Symfony\Component\ErrorHandler\Debug;
use Symfony\Component\HttpFoundation\Request;
require dirname(__DIR__) . '/vendor/autoload.php';
// Load cached env vars if the .env.local.php file exists
// Run "composer dump-env prod" to create it (requires symfony/flex >=1.2)
if (is_array($env = @include dirname(__DIR__).'/.env.local.php') && (!isset($env['APP_ENV']) || ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? $env['APP_ENV']) === $env['APP_ENV'])) {
foreach ($env as $k => $v) {
$_ENV[$k] = $_ENV[$k] ?? (isset($_SERVER[$k]) && 0 !== strpos($k, 'HTTP_') ? $_SERVER[$k] : $v);
}
} elseif (!class_exists(Dotenv::class)) {
throw new RuntimeException('Please run "composer require symfony/dotenv" to load the ".env" files configuring the application.');
} else {
// load all the .env files
(new Dotenv())->loadEnv(dirname(__DIR__).'/.env');
}
$_SERVER += $_ENV;
$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null) ?: 'dev';
$_SERVER['APP_DEBUG'] = $_SERVER['APP_DEBUG'] ?? $_ENV['APP_DEBUG'] ?? 'prod' !== $_SERVER['APP_ENV'];
$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = (int) $_SERVER['APP_DEBUG'] || filter_var($_SERVER['APP_DEBUG'], FILTER_VALIDATE_BOOLEAN) ? '1' : '0';
if ($_SERVER['APP_DEBUG']) {
umask(0000);
Debug::enable();
}
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);

View File

@@ -1,2 +0,0 @@
User-agent: *
Disallow: /

0
src/Controller/.gitignore vendored Normal file
View File

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Twig\Environment;
class AuthController extends AbstractController
{
public function __construct(private Environment $twig) {}
public function login(AuthenticationUtils $authenticationUtils): Response
{
$error = $authenticationUtils->getLastAuthenticationError();
$lastUsername = $authenticationUtils->getLastUsername();
$html = $this->twig->render('login.html.twig', [
'error' => $error ? $error->getMessage() : null,
'last_username' => $lastUsername
]);
return new Response($html);
}
public function logout(): void
{
// This method can be blank - it will be intercepted by the logout key on your firewall.
throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
}
}

View File

@@ -3,32 +3,20 @@ namespace App\Controller;
use App\Services\ExpenseService;
use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ExpenseController
class ExpenseController extends AbstractController
{
public function __construct(private ExpenseService $service) {}
private function ensureSession(): void
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start([
'cookie_httponly' => true,
'cookie_secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'cookie_samesite' => 'Lax',
]);
}
if (empty($_SESSION['uid'])) {
throw new \RuntimeException('Unauthorized');
}
}
public function create(Request $request): JsonResponse
{
$this->ensureSession();
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$data = json_decode($request->getContent() ?: '[]', true) ?: [];
try {
$expense = $this->service->create(
@@ -45,14 +33,16 @@ class ExpenseController
public function types(): JsonResponse
{
$this->ensureSession();
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$types = $this->service->listTypes();
return $this->success(['types' => $types]);
}
public function addType(Request $request): JsonResponse
{
$this->ensureSession();
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$data = json_decode($request->getContent() ?: '[]', true) ?: [];
try {
$this->service->addType((string)($data['name'] ?? ''));
@@ -64,7 +54,8 @@ class ExpenseController
public function listByDate(string $date): JsonResponse
{
$this->ensureSession();
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$dt = DateTimeImmutable::createFromFormat('Y-m-d', $date) ?: new DateTimeImmutable('today');
$items = $this->service->listByDate($dt);
return $this->success(['items' => $items, 'date' => $dt->format('Y-m-d')]);
@@ -72,16 +63,19 @@ class ExpenseController
public function datesWithExpenses(): JsonResponse
{
$this->ensureSession();
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$dates = $this->service->getDistinctDates();
return $this->success(['dates' => $dates]);
}
public function downloadCsv(): Response
{
$this->ensureSession();
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$all = $this->service->listAll();
$callback = function() use ($all) {
$response = new StreamedResponse(function() use ($all) {
$out = fopen('php://output', 'w');
fputcsv($out, ['ID', 'Amount', 'Type', 'Note', 'Spent At', 'Created At']);
foreach ($all as $row) {
@@ -95,11 +89,12 @@ class ExpenseController
]);
}
fclose($out);
};
return new Response(stream_callback: $callback, status: 200, headers: [
'Content-Type' => 'text/csv; charset=utf-8',
'Content-Disposition' => 'attachment; filename="expenses_'.(new \DateTimeImmutable('now'))->format('Ymd_His').'.csv"'
]);
});
$response->headers->set('Content-Type', 'text/csv; charset=utf-8');
$response->headers->set('Content-Disposition', 'attachment; filename="expenses_'.(new \DateTimeImmutable('now'))->format('Ymd_His').'.csv"');
return $response;
}
private function success(array $data): JsonResponse

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Controller;
use App\Services\ExpenseService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Twig\Environment;
class ExpenseTypeController extends AbstractController
{
public function __construct(
private Environment $twig,
private ExpenseService $service
) {}
public function manage(): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$types = $this->service->listTypesWithDetails();
$html = $this->twig->render('expense_type/manage.html.twig', [
'types' => $types,
]);
return new Response($html);
}
public function list(): JsonResponse
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$types = $this->service->listTypesWithDetails();
return $this->success(['types' => $types]);
}
public function add(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$data = json_decode($request->getContent() ?: '[]', true) ?: [];
try {
$this->service->addType((string)($data['name'] ?? ''));
$types = $this->service->listTypesWithDetails();
return $this->success(['message' => 'Type added successfully', 'types' => $types]);
} catch (\Throwable $e) {
return $this->error($e->getMessage(), Response::HTTP_BAD_REQUEST);
}
}
public function delete(int $id): JsonResponse
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
try {
$this->service->deleteType($id);
$types = $this->service->listTypesWithDetails();
return $this->success(['message' => 'Type deleted successfully', 'types' => $types]);
} catch (\Throwable $e) {
return $this->error($e->getMessage(), Response::HTTP_BAD_REQUEST);
}
}
private function success(array $data): JsonResponse
{
return new JsonResponse(['success' => true, 'data' => $data]);
}
private function error(string $message, int $status): JsonResponse
{
return new JsonResponse(['success' => false, 'error' => $message], $status);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Controller;
use App\Services\ExpenseService;
use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Twig\Environment;
class PageController extends AbstractController
{
public function __construct(
private Environment $twig,
private ExpenseService $expenses
) {}
public function daily(): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$today = new DateTimeImmutable('today');
$items = $this->expenses->listByDate($today);
$html = $this->twig->render('daily.html.twig', [
'today' => $today->format('Y-m-d'),
'items' => $items,
]);
return new Response($html);
}
}

125
src/Entity/Expense.php Normal file
View File

@@ -0,0 +1,125 @@
<?php
namespace App\Entity;
use App\Repository\ExpenseRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ExpenseRepository::class)]
#[ORM\Table(name: 'expenses')]
#[ORM\Index(name: 'idx_spent_at', columns: ['spent_at'])]
#[ORM\Index(name: 'idx_user_id', columns: ['user_id'])]
class Expense
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
private ?string $amount = null;
#[ORM\ManyToOne(targetEntity: ExpenseType::class)]
#[ORM\JoinColumn(nullable: false)]
private ?ExpenseType $type = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $note = null;
#[ORM\Column(type: 'date')]
private ?\DateTimeInterface $spentAt = null;
#[ORM\Column(type: 'datetime')]
private ?\DateTimeInterface $createdAt = null;
public function __construct()
{
$this->createdAt = new \DateTime();
}
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): self
{
$this->user = $user;
return $this;
}
public function getAmount(): ?string
{
return $this->amount;
}
public function setAmount(string $amount): self
{
$this->amount = $amount;
return $this;
}
public function getType(): ?ExpenseType
{
return $this->type;
}
public function setType(?ExpenseType $type): self
{
$this->type = $type;
return $this;
}
public function getNote(): ?string
{
return $this->note;
}
public function setNote(?string $note): self
{
$this->note = $note;
return $this;
}
public function getSpentAt(): ?\DateTimeInterface
{
return $this->spentAt;
}
public function setSpentAt(\DateTimeInterface $spentAt): self
{
$this->spentAt = $spentAt;
return $this;
}
public function getCreatedAt(): ?\DateTimeInterface
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeInterface $createdAt): self
{
$this->createdAt = $createdAt;
return $this;
}
public function toArray(): array
{
return [
'id' => $this->id,
'amount' => $this->amount,
'type' => $this->type?->getName(),
'note' => $this->note,
'spent_at' => $this->spentAt?->format('Y-m-d'),
'created_at' => $this->createdAt?->format('Y-m-d H:i:s'),
];
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Entity;
use App\Repository\ExpenseTypeRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ExpenseTypeRepository::class)]
#[ORM\Table(name: 'expense_types')]
#[ORM\UniqueConstraint(name: 'unique_user_type', columns: ['user_id', 'name'])]
class ExpenseType
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\Column(type: 'string', length: 100)]
private ?string $name = null;
#[ORM\Column(type: 'datetime')]
private ?\DateTimeInterface $createdAt = null;
public function __construct()
{
$this->createdAt = new \DateTime();
}
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): self
{
$this->user = $user;
return $this;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getCreatedAt(): ?\DateTimeInterface
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeInterface $createdAt): self
{
$this->createdAt = $createdAt;
return $this;
}
}

98
src/Entity/User.php Normal file
View File

@@ -0,0 +1,98 @@
<?php
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'users')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 180, unique: true)]
private ?string $email = null;
#[ORM\Column(type: 'json')]
private array $roles = [];
#[ORM\Column(type: 'string')]
private ?string $password = null;
#[ORM\Column(type: 'datetime')]
private ?\DateTimeInterface $createdAt = null;
public function __construct()
{
$this->createdAt = new \DateTime();
$this->roles = ['ROLE_USER'];
}
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
public function getUserIdentifier(): string
{
return (string) $this->email;
}
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
public function setRoles(array $roles): self
{
$this->roles = $roles;
return $this;
}
public function getPassword(): string
{
return $this->password;
}
public function setPassword(string $password): self
{
$this->password = $password;
return $this;
}
public function eraseCredentials(): void
{
// If you store any temporary, sensitive data on the user, clear it here
}
public function getCreatedAt(): ?\DateTimeInterface
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeInterface $createdAt): self
{
$this->createdAt = $createdAt;
return $this;
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Repository;
use App\Entity\Expense;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ExpenseRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Expense::class);
}
public function save(Expense $entity, bool $flush = false): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(Expense $entity, bool $flush = false): void
{
$this->getEntityManager()->remove($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
/**
* @return Expense[]
*/
public function findByUserAndDate(User $user, \DateTimeInterface $date): array
{
return $this->createQueryBuilder('e')
->andWhere('e.user = :user')
->andWhere('e.spentAt = :date')
->setParameter('user', $user)
->setParameter('date', $date->format('Y-m-d'))
->orderBy('e.createdAt', 'DESC')
->getQuery()
->getResult();
}
/**
* @return Expense[]
*/
public function findByUser(User $user): array
{
return $this->createQueryBuilder('e')
->andWhere('e.user = :user')
->setParameter('user', $user)
->orderBy('e.spentAt', 'DESC')
->addOrderBy('e.createdAt', 'DESC')
->getQuery()
->getResult();
}
/**
* @return string[]
*/
public function findDistinctDatesByUser(User $user): array
{
$expenses = $this->createQueryBuilder('e')
->select('e.spentAt')
->andWhere('e.user = :user')
->setParameter('user', $user)
->orderBy('e.spentAt', 'DESC')
->getQuery()
->getResult();
// Extract unique dates and format them
$dates = [];
foreach ($expenses as $expense) {
$date = $expense['spentAt'];
if ($date instanceof \DateTimeInterface) {
$dateStr = $date->format('Y-m-d');
if (!in_array($dateStr, $dates)) {
$dates[] = $dateStr;
}
}
}
return $dates;
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Repository;
use App\Entity\ExpenseType;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ExpenseTypeRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ExpenseType::class);
}
public function save(ExpenseType $entity, bool $flush = false): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(ExpenseType $entity, bool $flush = false): void
{
$this->getEntityManager()->remove($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
/**
* @return ExpenseType[]
*/
public function findByUser(User $user): array
{
return $this->createQueryBuilder('et')
->andWhere('et.user = :user')
->setParameter('user', $user)
->orderBy('et.name', 'ASC')
->getQuery()
->getResult();
}
public function findOneByUserAndName(User $user, string $name): ?ExpenseType
{
return $this->createQueryBuilder('et')
->andWhere('et.user = :user')
->andWhere('et.name = :name')
->setParameter('user', $user)
->setParameter('name', $name)
->getQuery()
->getOneOrNullResult();
}
public function initDefaultTypesForUser(User $user): void
{
$defaultTypes = ['Food', 'Transport', 'Utilities'];
foreach ($defaultTypes as $typeName) {
if (!$this->findOneByUserAndName($user, $typeName)) {
$type = new ExpenseType();
$type->setUser($user);
$type->setName($typeName);
$this->save($type);
}
}
$this->getEntityManager()->flush();
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
public function save(User $entity, bool $flush = false): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(User $entity, bool $flush = false): void
{
$this->getEntityManager()->remove($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
/**
* Used to upgrade (rehash) the user's password automatically over time.
*/
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', \get_class($user)));
}
$user->setPassword($newHashedPassword);
$this->save($user, true);
}
public function findOneByEmail(string $email): ?User
{
return $this->findOneBy(['email' => $email]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Security;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\SecurityRequestAttributes;
use Symfony\Component\Security\Http\Util\TargetPathTrait;
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
use TargetPathTrait;
public function __construct(private RouterInterface $router) {}
public function authenticate(Request $request): Passport
{
$email = $request->request->get('email', '');
$password = $request->request->get('password', '');
$request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $email);
return new Passport(
new UserBadge($email),
new PasswordCredentials($password),
[
new RememberMeBadge(),
]
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) {
return new RedirectResponse($targetPath);
}
return new RedirectResponse($this->router->generate('home'));
}
protected function getLoginUrl(Request $request): string
{
return $this->router->generate('login');
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Security;
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
class UserProvider implements UserProviderInterface, PasswordUpgraderInterface
{
public function __construct(private UserRepository $userRepository) {}
public function loadUserByIdentifier(string $identifier): UserInterface
{
$user = $this->userRepository->findOneByEmail($identifier);
if (!$user) {
throw new UserNotFoundException(sprintf('User "%s" not found.', $identifier));
}
return $user;
}
public function refreshUser(UserInterface $user): UserInterface
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Invalid user class "%s".', get_class($user)));
}
$refreshedUser = $this->userRepository->find($user->getId());
if (!$refreshedUser) {
throw new UserNotFoundException(sprintf('User with ID "%s" not found.', $user->getId()));
}
return $refreshedUser;
}
public function supportsClass(string $class): bool
{
return User::class === $class || is_subclass_of($class, User::class);
}
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', \get_class($user)));
}
$user->setPassword($newHashedPassword);
$this->userRepository->save($user, true);
}
}

View File

@@ -0,0 +1,174 @@
<?php
namespace App\Services;
use App\Entity\Expense;
use App\Entity\ExpenseType;
use App\Entity\User;
use App\Repository\ExpenseRepository;
use App\Repository\ExpenseTypeRepository;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
class ExpenseService
{
public function __construct(
private ExpenseRepository $expenseRepository,
private ExpenseTypeRepository $expenseTypeRepository,
private Security $security
) {}
private function getCurrentUser(): User
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new \RuntimeException('User must be logged in');
}
return $user;
}
public function listTypes(): array
{
$user = $this->getCurrentUser();
$types = $this->expenseTypeRepository->findByUser($user);
// Initialize default types if none exist
if (empty($types)) {
$this->expenseTypeRepository->initDefaultTypesForUser($user);
$types = $this->expenseTypeRepository->findByUser($user);
}
return array_map(fn(ExpenseType $type) => $type->getName(), $types);
}
public function listTypesWithDetails(): array
{
$user = $this->getCurrentUser();
$types = $this->expenseTypeRepository->findByUser($user);
// Initialize default types if none exist
if (empty($types)) {
$this->expenseTypeRepository->initDefaultTypesForUser($user);
$types = $this->expenseTypeRepository->findByUser($user);
}
return array_map(fn(ExpenseType $type) => [
'id' => $type->getId(),
'name' => $type->getName(),
'created_at' => $type->getCreatedAt()->format('Y-m-d H:i:s'),
], $types);
}
public function addType(string $name): void
{
$user = $this->getCurrentUser();
$name = trim($name);
if ($name === '') {
throw new \InvalidArgumentException('Type name cannot be empty');
}
// Check if type already exists
$existingType = $this->expenseTypeRepository->findOneByUserAndName($user, $name);
if ($existingType) {
throw new \InvalidArgumentException('Type already exists');
}
$type = new ExpenseType();
$type->setUser($user);
$type->setName($name);
$this->expenseTypeRepository->save($type, true);
}
public function deleteType(int $typeId): void
{
$user = $this->getCurrentUser();
$type = $this->expenseTypeRepository->find($typeId);
if (!$type) {
throw new \InvalidArgumentException('Type not found');
}
if ($type->getUser()->getId() !== $user->getId()) {
throw new \RuntimeException('You do not have permission to delete this type');
}
// Check if type is used in any expenses
$expenses = $this->expenseRepository->createQueryBuilder('e')
->where('e.type = :type')
->setParameter('type', $type)
->setMaxResults(1)
->getQuery()
->getResult();
if (!empty($expenses)) {
throw new \InvalidArgumentException('Cannot delete type that is being used in expenses');
}
$this->expenseTypeRepository->remove($type, true);
}
public function create(string $amount, string $typeName, ?string $note, string $spentAt): array
{
$user = $this->getCurrentUser();
$amount = trim($amount);
$typeName = trim($typeName);
$note = $note !== null ? trim($note) : null;
$spentAt = trim($spentAt);
if ($amount === '' || !is_numeric($amount)) {
throw new \InvalidArgumentException('Invalid amount');
}
if ($typeName === '') {
throw new \InvalidArgumentException('Type is required');
}
$spentAtDate = \DateTimeImmutable::createFromFormat('Y-m-d', $spentAt);
if (!$spentAtDate) {
throw new \InvalidArgumentException('Invalid spent_at date');
}
// Find or create expense type
$type = $this->expenseTypeRepository->findOneByUserAndName($user, $typeName);
if (!$type) {
$type = new ExpenseType();
$type->setUser($user);
$type->setName($typeName);
$this->expenseTypeRepository->save($type, true);
}
$expense = new Expense();
$expense->setUser($user);
$expense->setAmount(number_format((float)$amount, 2, '.', ''));
$expense->setType($type);
$expense->setNote($note);
$expense->setSpentAt($spentAtDate);
$this->expenseRepository->save($expense, true);
return $expense->toArray();
}
public function listByDate(DateTimeImmutable $date): array
{
$user = $this->getCurrentUser();
$expenses = $this->expenseRepository->findByUserAndDate($user, $date);
return array_map(fn(Expense $expense) => $expense->toArray(), $expenses);
}
public function getDistinctDates(): array
{
$user = $this->getCurrentUser();
return $this->expenseRepository->findDistinctDatesByUser($user);
}
public function listAll(): array
{
$user = $this->getCurrentUser();
$expenses = $this->expenseRepository->findByUser($user);
return array_map(fn(Expense $expense) => $expense->toArray(), $expenses);
}
}

View File

@@ -1,40 +0,0 @@
{
"name": "appsynchq/expenses",
"type": "project",
"description": "Expenses app migrated to Symfony 6.4",
"license": "MIT",
"require": {
"php": ">=8.1",
"ext-pdo": "*",
"symfony/framework-bundle": "^6.4",
"symfony/http-foundation": "^6.4",
"symfony/routing": "^6.4",
"symfony/dependency-injection": "^6.4",
"symfony/yaml": "^6.4",
"symfony/twig-bundle": "^6.4"
},
"require-dev": {
"symfony/var-dumper": "^6.4"
},
"autoload": {
"psr-4": {
"App\\": "src/"
},
"classmap": [
"ExpenseService.php",
"ExpenseRepository.php",
"auth_service.php",
"auth_repository.php",
"DatabaseConnectionHandler.php",
"JsonResponseHandler.php"
]
},
"scripts": {
"post-install-cmd": [
"@php bin/console cache:clear || exit 0"
],
"post-update-cmd": [
"@php bin/console cache:clear || exit 0"
]
}
}

View File

@@ -1,5 +0,0 @@
<?php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
];

View File

@@ -1,12 +0,0 @@
framework:
secret: '%env(APP_SECRET)%'
http_method_override: false
session:
handler_id: null
cookie_secure: auto
cookie_samesite: lax
router:
utf8: true
csrf_protection: false
php_errors:
log: true

View File

@@ -1,27 +0,0 @@
parameters:
env(APP_ENV): 'dev'
env(APP_DEBUG): '1'
env(APP_SECRET): 'change-me-secret'
env(DB_DSN): ''
env(DB_USER): ''
env(DB_PASS): ''
services:
_defaults:
autowire: true
autoconfigure: true
bind:
string $dsn: '%env(DB_DSN)%'
string $dbUser: '%env(DB_USER)%'
string $dbPass: '%env(DB_PASS)%'
App\:
resource: '../src/*'
exclude: '../src/{Entity,Tests,Kernel.php}'
# Register existing DB wrapper as a service
App\Lib\Database:
arguments:
$dsn: '%env(DB_DSN)%'
$user: '%env(DB_USER)%'
$pass: '%env(DB_PASS)%'

View File

@@ -1,17 +0,0 @@
<?php
use App\Kernel;
use Symfony\Component\ErrorHandler\Debug;
use Symfony\Component\HttpFoundation\Request;
require dirname(__DIR__) . '/vendor/autoload.php';
if ($_SERVER['APP_DEBUG'] ?? true) {
umask(0000);
Debug::enable();
}
$kernel = new Kernel($_SERVER['APP_ENV'] ?? 'dev', (bool)($_SERVER['APP_DEBUG'] ?? true));
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);

View File

@@ -1,47 +0,0 @@
<?php
namespace App\Controller;
use App\Services\AuthService;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Twig\Environment;
class AuthController
{
public function __construct(private Environment $twig, private AuthService $auth) {}
private function ensureSession(): void
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start([
'cookie_httponly' => true,
'cookie_secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'cookie_samesite' => 'Lax',
]);
}
}
public function login(Request $request): Response
{
$this->ensureSession();
$error = null;
if ($request->isMethod('POST')) {
try {
$this->auth->login((string)$request->request->get('email', ''), (string)$request->request->get('password', ''));
return new RedirectResponse('/');
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
$html = $this->twig->render('login.html.twig', ['error' => $error]);
return new Response($html);
}
public function logout(): Response
{
$this->ensureSession();
$this->auth->logout();
return new RedirectResponse('/login');
}
}

View File

@@ -1,40 +0,0 @@
<?php
namespace App\Controller;
use App\Services\ExpenseService;
use DateTimeImmutable;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Twig\Environment;
class PageController
{
public function __construct(private Environment $twig, private ExpenseService $expenses) {}
private function ensureSession(): void
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start([
'cookie_httponly' => true,
'cookie_secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'cookie_samesite' => 'Lax',
]);
}
}
public function daily(): Response
{
$this->ensureSession();
if (empty($_SESSION['uid'])) {
return new RedirectResponse('/login');
}
$today = (new DateTimeImmutable('today'));
$items = $this->expenses->listByDate($today);
$html = $this->twig->render('daily.html.twig', [
'today' => $today->format('Y-m-d'),
'items' => $items,
]);
return new Response($html);
}
}

View File

@@ -18,11 +18,11 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="mb-0">Daily Expenses</h1>
<div class="d-flex gap-2">
<form method="post" action="/logout">
<form method="post" action="{{ path('logout') }}">
<button type="submit" class="btn btn-outline-secondary btn-sm">Logout</button>
</form>
<a class="btn btn-outline-primary btn-sm" href="/expenses/download.csv">Download CSV</a>
<a class="btn btn-outline-success btn-sm" href="/manage_types_page.php">Manage Types</a>
<a class="btn btn-outline-success btn-sm" href="{{ path('manage_types') }}">Manage Types</a>
</div>
</div>
@@ -89,7 +89,7 @@
{% else %}
{% for e in items %}
<tr>
<td>JD{{ (e.amount|float)|number_format(2, '.', ',') }}</td>
<td>JD{{ (e.amount|number_format(2, '.', ',')) }}</td>
<td>{{ e.type|e }}</td>
<td>{{ (e.note ?? '')|e }}</td>
<td>{{ e.spent_at|e }}</td>

View File

@@ -0,0 +1,227 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Manage Expense Types</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="mb-0">Manage Expense Types</h1>
<div class="d-flex gap-2">
<a class="btn btn-outline-primary btn-sm" href="{{ path('home') }}">Back to Expenses</a>
<form method="post" action="{{ path('logout') }}">
<button type="submit" class="btn btn-outline-secondary btn-sm">Logout</button>
</form>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Add New Type</h5>
</div>
<div class="card-body">
<form id="add-type-form" novalidate>
<div class="row g-3">
<div class="col-md-8">
<label for="type-name" class="form-label">Type Name</label>
<input type="text" class="form-control" id="type-name" name="name" placeholder="e.g., Entertainment" required>
<div class="invalid-feedback">Please provide a type name.</div>
</div>
<div class="col-md-4 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">Add Type</button>
</div>
</div>
</form>
<div id="alert-placeholder" class="mt-3"></div>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="mb-0">Your Expense Types</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped table-hover mb-0" id="types-table">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Created At</th>
<th style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody>
{% if types is empty %}
<tr><td colspan="3" class="text-center text-muted py-4">No types yet.</td></tr>
{% else %}
{% for type in types %}
<tr data-type-id="{{ type.id }}">
<td>{{ type.name|e }}</td>
<td>{{ type.created_at|e }}</td>
<td>
<button class="btn btn-sm btn-danger delete-type-btn" data-type-id="{{ type.id }}" data-type-name="{{ type.name|e }}">
Delete
</button>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel">Confirm Delete</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure you want to delete the type "<strong id="delete-type-name"></strong>"?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirm-delete-btn">Delete</button>
</div>
</div>
</div>
</div>
<script src="https://code.jquery.com/jquery-3.7.1.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script type="text/javascript">
(function ($) {
var deleteModal;
var typeIdToDelete = null;
function showAlert(type, message) {
$('#alert-placeholder').html(
'<div class="alert alert-' + type + ' alert-dismissible" role="alert">' +
message +
'<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>' +
'</div>'
);
}
function renderTypes(types) {
var $tbody = $('#types-table tbody');
if (!types || !types.length) {
$tbody.html('<tr><td colspan="3" class="text-center text-muted py-4">No types yet.</td></tr>');
return;
}
var html = types.map(function (type) {
return '<tr data-type-id="' + type.id + '">'
+ '<td>' + $('<div>').text(type.name).html() + '</td>'
+ '<td>' + $('<div>').text(type.created_at).html() + '</td>'
+ '<td>'
+ '<button class="btn btn-sm btn-danger delete-type-btn" data-type-id="' + type.id + '" data-type-name="' + $('<div>').text(type.name).html() + '">Delete</button>'
+ '</td>'
+ '</tr>';
}).join('');
$tbody.html(html);
}
function loadTypes() {
$.getJSON('/types/list')
.done(function (res) {
if (res.success) {
renderTypes(res.data.types);
} else {
showAlert('danger', res.error || 'Failed to load types.');
}
})
.fail(function () {
showAlert('danger', 'Failed to load types.');
});
}
$(function () {
deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
// Add type form submission
$('#add-type-form').on('submit', function (e) {
e.preventDefault();
var form = this;
form.classList.add('was-validated');
if (!form.checkValidity()) {
return;
}
var typeName = $('#type-name').val();
$.ajax({
url: '/types/add',
method: 'POST',
data: JSON.stringify({ name: typeName }),
contentType: 'application/json',
dataType: 'json'
}).done(function (res) {
if (res.success) {
showAlert('success', res.data.message || 'Type added successfully.');
renderTypes(res.data.types);
form.reset();
form.classList.remove('was-validated');
} else {
showAlert('danger', res.error || 'Failed to add type.');
}
}).fail(function (xhr) {
var msg = 'Request failed.';
if (xhr.responseJSON && xhr.responseJSON.error) {
msg = xhr.responseJSON.error;
}
showAlert('danger', msg);
});
});
// Delete type button click
$(document).on('click', '.delete-type-btn', function () {
typeIdToDelete = $(this).data('type-id');
var typeName = $(this).data('type-name');
$('#delete-type-name').text(typeName);
deleteModal.show();
});
// Confirm delete
$('#confirm-delete-btn').on('click', function () {
if (!typeIdToDelete) return;
$.ajax({
url: '/types/delete/' + typeIdToDelete,
method: 'DELETE',
dataType: 'json'
}).done(function (res) {
deleteModal.hide();
if (res.success) {
showAlert('success', res.data.message || 'Type deleted successfully.');
renderTypes(res.data.types);
} else {
showAlert('danger', res.error || 'Failed to delete type.');
}
typeIdToDelete = null;
}).fail(function (xhr) {
deleteModal.hide();
var msg = 'Request failed.';
if (xhr.responseJSON && xhr.responseJSON.error) {
msg = xhr.responseJSON.error;
}
showAlert('danger', msg);
typeIdToDelete = null;
});
});
});
})(jQuery);
</script>
</body>
</html>

View File

@@ -13,10 +13,10 @@
{% if error %}
<div class="alert alert-danger">{{ error|e }}</div>
{% endif %}
<form method="post" novalidate>
<form method="post" action="{{ path('login') }}" novalidate>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" required autofocus>
<input type="email" class="form-control" id="email" name="email" value="{{ last_username|default('') }}" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
@@ -24,6 +24,12 @@
</div>
<button class="btn btn-primary w-100" type="submit">Sign in</button>
</form>
<p class="mt-3 text-muted small">Demo: Enter any email and password to auto-create an account</p>
{% if app.user %}
<div class="alert alert-info mt-3">
You are logged in as {{ app.user.userIdentifier }}
</div>
{% endif %}
</div>
</body>
</html>

10
tests/autoload_check.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', '1');
require __DIR__ . '/../vendor/autoload.php';
echo "autoload OK\n";
echo function_exists('grapheme_strlen') ? "grapheme OK\n" : "grapheme MISSING\n";
echo function_exists('normalizer_normalize') ? "normalizer OK\n" : "normalizer MISSING\n";