Skip to content
Merged
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ A REST-ful API designed to manage personal financial expenses and categories. Th

## Features

- [ ] Category Management (CRUD): Add, view, update, and delete spending categories
- [ ] Record transactions with details like description, amount and date, linking each to a specific category
- [x] Category Management (CRUD): Add, view, update, and delete spending categories
- [ ] Record expenses with details like description, amount and date, linking each to a specific category
- [ ] Uses `Decimal(10, 2)` for amount to ensure accurate financial calculations.
- [ ] Auditing: Automatic timestamping for creation and last update (`createdAt`, `updateAt`).
- [ ] Documentation: Fully documented endpoints using Swagger (OpenAPI).
Expand Down
64 changes: 63 additions & 1 deletion Requests.http
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,66 @@ Content-Type: application/json
}

### Delete a category
DELETE http://localhost:8080/api/categories/3
DELETE http://localhost:8080/api/categories/3

### Create a new expense
POST http://localhost:8080/api/expenses
Content-Type: application/json

{
"amount": 1200,
"description": "January Rent",
"categoryId": 1
}

### Create another expense
POST http://localhost:8080/api/expenses
Content-Type: application/json

{
"amount": 1500,
"description": "February Rent",
"categoryId": 1
}

### Get all expenses
GET http://localhost:8080/api/expenses

### Get a single expense
GET http://localhost:8080/api/expenses/1

### Update an expense
PUT http://localhost:8080/api/expenses/2
Content-Type: application/json

{
"amount": 1600,
"description": "February Rent Updated",
"categoryId": 1
}

### Create an expense with missing fields
POST http://localhost:8080/api/expenses
Content-Type: application/json

{
"amount": 300,
"expense_description": "Groceries",
"categoryId": 4
}

### Create an expense with a non-existent category
POST http://localhost:8080/api/expenses
Content-Type: application/json

{
"amount": 300,
"description": "Groceries",
"categoryId": 99
}

### Delete an expense
DELETE http://localhost:8080/api/expenses/1

### Delete a non-existent expense
DELETE http://localhost:8080/api/expenses/99

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.muchemi.expense_tracker.controllers;

import com.muchemi.expense_tracker.dto.CategoryRequestDTO;
import com.muchemi.expense_tracker.dto.CategoryResponseDTO;
import com.muchemi.expense_tracker.dto.category.CategoryRequestDTO;
import com.muchemi.expense_tracker.dto.category.CategoryResponseDTO;
import com.muchemi.expense_tracker.services.CategoryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.muchemi.expense_tracker.controllers;


import com.muchemi.expense_tracker.dto.expense.ExpenseRequestDTO;
import com.muchemi.expense_tracker.dto.expense.ExpenseResponseDTO;
import com.muchemi.expense_tracker.repositories.CategoryRepository;
import com.muchemi.expense_tracker.services.CategoryService;
import com.muchemi.expense_tracker.services.ExpenseService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("api/expenses")
public class ExpenseController {
private final ExpenseService expenseService;
private final CategoryService categoryService;

@Autowired
public ExpenseController(ExpenseService expenseService, CategoryService categoryService) {
this.categoryService = categoryService;
this.expenseService = expenseService;
}

@PostMapping
public ResponseEntity<ExpenseResponseDTO> createExpense(@RequestBody ExpenseRequestDTO dto) {
ExpenseResponseDTO response = expenseService.createNewExpense(dto);

return ResponseEntity.ok(response);
}

@GetMapping
public ResponseEntity<List<ExpenseResponseDTO>> getAllExpenses() {
List<ExpenseResponseDTO> expenses = expenseService.getAllExpenses();

return ResponseEntity.ok(expenses);
}

@GetMapping("{id}")
public ResponseEntity<ExpenseResponseDTO> getExpenseById(@PathVariable Long id) {
ExpenseResponseDTO response = expenseService.getExpenseById(id);
return ResponseEntity.ok(response);
}

@PutMapping("{id}")
public ResponseEntity<ExpenseResponseDTO> updateExpense(@PathVariable Long id, @RequestBody ExpenseRequestDTO dto) {
ExpenseResponseDTO updatedExpense = expenseService.updateExpense(id, dto);
return ResponseEntity.ok(updatedExpense);
}

@DeleteMapping("{id}")
public ResponseEntity<Long> deleteExpenseById(@PathVariable Long id) {
Long deletedId = expenseService.removeExpenseById(id);
return ResponseEntity.ok(deletedId);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.muchemi.expense_tracker.dto;
package com.muchemi.expense_tracker.dto.category;

public record CategoryRequestDTO(String name) {
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.muchemi.expense_tracker.dto;
package com.muchemi.expense_tracker.dto.category;

public record CategoryResponseDTO(Long id, String name) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.muchemi.expense_tracker.dto.expense;

import java.math.BigDecimal;
import java.time.LocalDate;

public record ExpenseRequestDTO(
String description,
BigDecimal amount,
Long categoryId,
LocalDate date
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.muchemi.expense_tracker.dto.expense;

import java.math.BigDecimal;
import java.time.LocalDate;

public record ExpenseResponseDTO(Long id, String description, BigDecimal amount, LocalDate date, Long categoryId) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.muchemi.expense_tracker.dto.mappers;

import com.muchemi.expense_tracker.dto.expense.ExpenseResponseDTO;
import com.muchemi.expense_tracker.entities.Expense;

public class ExpenseResponseDTOMapper {
public static ExpenseResponseDTO toDTO(Expense expense) {
return new ExpenseResponseDTO(
expense.getId(),
expense.getDescription(),
expense.getAmount(),
expense.getDate(),
expense.getCategory().getId()
);
}
}
11 changes: 8 additions & 3 deletions src/main/java/com/muchemi/expense_tracker/entities/Expense.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Check;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;

@Entity
@Table(name = "expense")
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor
@AllArgsConstructor
@Data
Expand All @@ -30,15 +32,18 @@ public class Expense {
@Check(constraints = "amount > 0", name = "amount_must_be_positive")
private BigDecimal amount;

@Column(nullable = false)
private LocalDate date;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id", nullable = false)
private Category category;

@CreatedDate
@CreationTimestamp
@Column(nullable = false, updatable = false)
private Instant createdAt;

@LastModifiedDate
@UpdateTimestamp
@Column(nullable = false)
private Instant updatedAt;
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,18 @@ public ResponseEntity<Object> handleResourceConflictException(ResourceConflictEx
return new ResponseEntity<>(body, HttpStatus.CONFLICT);
}

@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Object> handleIllegalArgumentException(IllegalArgumentException ex, WebRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", Instant.now());
body.put("status", HttpStatus.BAD_REQUEST.value());
body.put("error", "Bad Request");
body.put("message", ex.getMessage());
body.put("path", request.getDescription(false).replace("uri=", ""));

return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
}

@ExceptionHandler(Exception.class)
public ResponseEntity<Object> handleGenericException(Exception ex, WebRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
import org.springframework.data.jpa.repository.JpaRepository;

public interface ExpenseRepository extends JpaRepository<Expense, Long> {
boolean existsByDescriptionIgnoreCase(String normalizedDescription);
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.muchemi.expense_tracker.services;

import com.muchemi.expense_tracker.dto.CategoryRequestDTO;
import com.muchemi.expense_tracker.dto.CategoryResponseDTO;
import com.muchemi.expense_tracker.dto.category.CategoryRequestDTO;
import com.muchemi.expense_tracker.dto.category.CategoryResponseDTO;
import com.muchemi.expense_tracker.entities.Category;
import com.muchemi.expense_tracker.exceptions.ResourceConflictException;
import com.muchemi.expense_tracker.exceptions.ResourceNotFoundException;
Expand All @@ -11,6 +11,7 @@

import java.util.List;

// TODO: Refactor to reduce code duplication
@Service
public class CategoryService {
private static CategoryRepository categoryRepository = null;
Expand All @@ -20,7 +21,7 @@ public CategoryService(CategoryRepository categoryRepository) {
}

public CategoryResponseDTO createNewCategory(CategoryRequestDTO dto) {
if (dto.name() == null || dto.name().trim().isEmpty()) {
if (dto.name() == null || dto.name().trim().isEmpty()) {
throw new ResourceConflictException("Category name cannot be empty or null.");
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package com.muchemi.expense_tracker.services;

import com.muchemi.expense_tracker.dto.expense.ExpenseRequestDTO;
import com.muchemi.expense_tracker.dto.expense.ExpenseResponseDTO;
import com.muchemi.expense_tracker.dto.mappers.ExpenseResponseDTOMapper;
import com.muchemi.expense_tracker.entities.Category;
import com.muchemi.expense_tracker.entities.Expense;
import com.muchemi.expense_tracker.exceptions.ResourceNotFoundException;
import com.muchemi.expense_tracker.repositories.CategoryRepository;
import com.muchemi.expense_tracker.repositories.ExpenseRepository;
import org.springframework.stereotype.Service;

import java.time.LocalDate;
import java.util.List;

@Service
public class ExpenseService {
private final ExpenseRepository expenseRepository;
private final CategoryRepository categoryRepository;

public ExpenseService(ExpenseRepository expenseRepository, CategoryRepository categoryRepository) {
this.expenseRepository = expenseRepository;
this.categoryRepository = categoryRepository;
}

public boolean isDescriptionTaken(String description) {
String normalizedDescription = description.trim().toLowerCase();
return expenseRepository.existsByDescriptionIgnoreCase(normalizedDescription);
}

public ExpenseResponseDTO createNewExpense(ExpenseRequestDTO dto) {
Expense preparedExpense = prepareExpense(new Expense(), dto);

try {
Expense savedExpense = expenseRepository.save(preparedExpense);
return ExpenseResponseDTOMapper.toDTO(savedExpense);
} catch (Exception exception) {
throw new RuntimeException("An error occurred while saving the expense: " + exception.getMessage());
}
}

public List<ExpenseResponseDTO> getAllExpenses() {
return expenseRepository.findAll()
.stream()
.map(ExpenseResponseDTOMapper::toDTO)
.toList();
}

public ExpenseResponseDTO getExpenseById(Long id) {
Expense expense = expenseRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Expense not found with id: " + id));
return ExpenseResponseDTOMapper.toDTO(expense);
}

public Long removeExpenseById(Long id) {
if (!expenseRepository.existsById(id)) {
throw new ResourceNotFoundException("Expense not found with id: " + id);
}
expenseRepository.deleteById(id);
return id;
}

public ExpenseResponseDTO updateExpense(Long id, ExpenseRequestDTO dto) {
Expense expense = expenseRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Expense not found with id: " + id));
Expense preparedExpense = prepareExpense(expense, dto);

try {
Expense updatedExpense = expenseRepository.save(preparedExpense);
return ExpenseResponseDTOMapper.toDTO(updatedExpense);
} catch (Exception exception) {
throw new RuntimeException("An error occurred while updating the expense: " + exception.getMessage());
}
}

private Expense prepareExpense(Expense expense, ExpenseRequestDTO dto) {
if (dto.description() == null || dto.description().trim().isEmpty()) {
throw new IllegalArgumentException("Expense description cannot be empty or null.");
}
if (dto.description().length() > 255) {
throw new IllegalArgumentException("Expense description cannot exceed 255 characters.");
}
if (isDescriptionTaken(dto.description())) {
throw new IllegalArgumentException("Expense description '" + dto.description() + "' already exists.");
}
LocalDate expenseDate = dto.date() != null ? dto.date() : LocalDate.now();
expense.setDate(expenseDate);

Category category = categoryRepository.findById(dto.categoryId())
.orElseThrow(() -> new ResourceNotFoundException("Category not found with id: " + dto.categoryId()));

expense.setDescription(dto.description());
expense.setAmount(dto.amount());
expense.setCategory(category);

return expense;
}
}
Loading