Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/init.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

use App\Models\Transaction;
use App\Models\Category;
use App\Models\Tag;

echo "Creating database tables..." . PHP_EOL;

Expand All @@ -15,6 +16,7 @@

$classes = [
$em->getClassMetadata(Category::class),
$em->getClassMetadata(Tag::class),
$em->getClassMetadata(Transaction::class),
];

Expand Down
1 change: 1 addition & 0 deletions backend/public/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
});

// Transaction routes
$app->get('/api/v1/transactions/grid', [TransactionController::class, 'grid']);
$app->get('/api/v1/transactions[/]', [TransactionController::class, 'index']);
$app->get('/api/v1/transactions/{id}', [TransactionController::class, 'show']);
$app->post('/api/v1/transactions[/]', [TransactionController::class, 'create']);
Expand Down
72 changes: 70 additions & 2 deletions backend/seed.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@

use App\Models\Transaction;
use App\Models\Category;
use App\Models\Tag;

echo "Clearing existing data..." . PHP_EOL;

$em = createEntityManager();
$conn = $em->getConnection();

$conn->executeStatement("TRUNCATE TABLE transactions, categories RESTART IDENTITY CASCADE");
$conn->executeStatement("TRUNCATE TABLE transaction_tags, transactions, tags, categories RESTART IDENTITY CASCADE");

echo "Loading seed data from centralized JSON files..." . PHP_EOL;

Expand All @@ -34,6 +35,69 @@

$em->flush();

// Create tags
echo "Creating tags..." . PHP_EOL;
$tagNames = ['work', 'travel', 'reimbursable', 'personal', 'business', 'recurring', 'one-time'];
$tags = [];

foreach ($tagNames as $tagName) {
$tag = new Tag();
$tag->setName($tagName);
$em->persist($tag);
$tags[$tagName] = $tag;
}

$em->flush();

// Helper function to assign tags based on transaction data
$assignTags = function($transaction, $data) use ($tags) {
$description = strtolower($data['description']);
$category = strtolower($data['category']);

// Assign tags based on description and category
if (strpos($description, 'salary') !== false ||
strpos($description, 'freelance') !== false ||
strpos($description, 'bonus') !== false ||
strpos($description, 'dividend') !== false ||
$category === 'income' ||
$category === 'work') {
$transaction->addTag($tags['work']);
}

if (strpos($description, 'travel') !== false ||
strpos($description, 'transportation') !== false ||
strpos($description, 'gas') !== false ||
$category === 'travel' ||
$category === 'transport') {
$transaction->addTag($tags['travel']);
}

if (strpos($description, 'rent') !== false ||
strpos($description, 'subscription') !== false ||
strpos($description, 'membership') !== false ||
strpos($description, 'bill') !== false) {
$transaction->addTag($tags['recurring']);
} else {
$transaction->addTag($tags['one-time']);
}

if ($category === 'work' ||
strpos($description, 'software') !== false ||
strpos($description, 'license') !== false) {
$transaction->addTag($tags['business']);
} else {
$transaction->addTag($tags['personal']);
}

// Mark some transactions as reimbursable
if (strpos($description, 'travel') !== false ||
strpos($description, 'transportation') !== false ||
strpos($description, 'gas') !== false ||
($category === 'work' && $data['type'] === 'debit')) {
$transaction->addTag($tags['reimbursable']);
}
};

foreach ($transactionsData as $data) {
$transaction = new Transaction();
$transaction->setDescription($data['description']);
Expand All @@ -42,10 +106,14 @@
$transaction->setCategory($categories[$data['category']]);
$transaction->setUserId($data['user_id']);
$transaction->setDate(new DateTime($data['date']));

// Assign tags to transaction
$assignTags($transaction, $data);

$em->persist($transaction);
}

$em->flush();

echo "Database seeded successfully." . PHP_EOL;
echo "Seeded " . count($categoriesData) . " categories and " . count($transactionsData) . " transactions" . PHP_EOL;
echo "Seeded " . count($categoriesData) . " categories, " . count($tagNames) . " tags, and " . count($transactionsData) . " transactions" . PHP_EOL;
131 changes: 131 additions & 0 deletions backend/src/Controllers/TransactionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use App\Models\Transaction;
use App\Models\Category;
use Doctrine\DBAL\ParameterType;
use Doctrine\ORM\EntityManager;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
Expand All @@ -26,6 +27,8 @@ public function index(Request $request, Response $response): Response
$transactionRepo = $this->em->getRepository(Transaction::class);

$transactions = $transactionRepo->createQueryBuilder('t')
->leftJoin('t.category', 'c')
->addSelect('c')
->setFirstResult($skip)
->setMaxResults($limit)
->getQuery()
Expand Down Expand Up @@ -174,4 +177,132 @@ public function delete(Request $request, Response $response, array $args): Respo
$response->getBody()->write(json_encode($data, JSON_PRESERVE_ZERO_FRACTION));
return $response->withHeader('Content-Type', 'application/json');
}

public function grid(Request $request, Response $response): Response
{
try {
$params = $request->getQueryParams();
$page = isset($params['page']) ? max(1, (int)$params['page']) : 1;
$size = isset($params['size']) ? max(1, (int)$params['size']) : 10;
$sortBy = $params['sort_by'] ?? 'date';
$sortOrder = strtolower($params['sort_order'] ?? 'desc');

// Validate sort_order
if ($sortOrder !== 'asc' && $sortOrder !== 'desc') {
$sortOrder = 'desc';
}

// Validate and sanitize sort_by column - use whitelist approach
$columnMap = [
'date' => 't.date',
'description' => 't.description',
'amount' => 't.amount',
'category' => 'c.name',
'id' => 't.id',
];

// Only use columns from the whitelist
if (!isset($columnMap[$sortBy])) {
$sortBy = 'date';
}
$orderByColumn = $columnMap[$sortBy];
$orderDirection = $sortOrder === 'asc' ? 'ASC' : 'DESC';

$offset = ($page - 1) * $size;

$conn = $this->em->getConnection();

// Get total count
$countSql = "
SELECT COUNT(DISTINCT t.id) as total
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
";
$totalResult = $conn->fetchAssociative($countSql);
$total = (int)($totalResult['total'] ?? 0);

// Get paginated and sorted transactions with tags
// Build ORDER BY clause safely - column names are whitelisted so safe to interpolate
$sql = "
SELECT
t.id,
t.description,
t.amount,
t.type,
t.date,
t.user_id,
t.category_id,
c.id as category_id_rel,
c.name as category_name,
COALESCE(
JSON_AGG(
JSON_BUILD_OBJECT('id', tag.id, 'name', tag.name)
ORDER BY tag.name
) FILTER (WHERE tag.id IS NOT NULL),
'[]'::json
) as tags
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN transaction_tags tt ON t.id = tt.transaction_id
LEFT JOIN tags tag ON tt.tag_id = tag.id
GROUP BY t.id, t.description, t.amount, t.type, t.date, t.user_id, t.category_id, c.id, c.name
ORDER BY " . $orderByColumn . " " . $orderDirection . "
LIMIT ? OFFSET ?
";

$result = $conn->executeQuery($sql, [$size, $offset], [ParameterType::INTEGER, ParameterType::INTEGER]);
$rows = $result->fetchAllAssociative();

// Format the response
$items = array_map(function($row) {
$tags = json_decode($row['tags'], true);
if (!is_array($tags)) {
$tags = [];
}

// Format date to match API format
$date = $row['date'];
if ($date instanceof \DateTime) {
$date = $date->format('Y-m-d\TH:i:s\Z');
} elseif (is_string($date)) {
// Try to parse and format if it's a string
try {
$dateObj = new \DateTime($date);
$date = $dateObj->format('Y-m-d\TH:i:s\Z');
} catch (\Exception $e) {
// Keep original if parsing fails
}
}

return [
'id' => (int)$row['id'],
'description' => $row['description'],
'amount' => (float)$row['amount'],
'type' => $row['type'],
'date' => $date,
'user_id' => (int)$row['user_id'],
'category_rel' => [
'id' => (int)$row['category_id_rel'],
'name' => $row['category_name'],
],
'tags' => $tags,
];
}, $rows);

$data = [
'items' => $items,
'total' => $total,
];

$response->getBody()->write(json_encode($data, JSON_PRESERVE_ZERO_FRACTION));
return $response->withHeader('Content-Type', 'application/json');
} catch (\Exception $e) {
$error = [
'detail' => 'An error occurred while fetching transactions',
'message' => $e->getMessage(),
];
$response->getBody()->write(json_encode($error));
return $response->withHeader('Content-Type', 'application/json')->withStatus(500);
}
}
}
77 changes: 77 additions & 0 deletions backend/src/Models/Tag.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

namespace App\Models;

use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use DateTime;

#[ORM\Entity]
#[ORM\Table(name: 'tags')]
class Tag
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null; // @phpstan-ignore property.unusedType

#[ORM\Column(type: 'string', length: 255, unique: true)]
private string $name;

#[ORM\Column(type: 'datetime')]
private DateTime $created_at;

#[ORM\Column(type: 'datetime')]
private DateTime $updated_at;

#[ORM\ManyToMany(targetEntity: Transaction::class, mappedBy: 'tags')]
private Collection $transactions;

public function __construct()
{
$this->transactions = new ArrayCollection();
$this->created_at = new DateTime();
$this->updated_at = new DateTime();
}

public function getId(): ?int
{
return $this->id;
}

public function getName(): string
{
return $this->name;
}

public function setName(string $name): self
{
$this->name = $name;
$this->updated_at = new DateTime();
return $this;
}

public function getCreatedAt(): DateTime
{
return $this->created_at;
}

public function getUpdatedAt(): DateTime
{
return $this->updated_at;
}

public function getTransactions(): Collection
{
return $this->transactions;
}

public function toArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
];
}
}
Loading