scala Tutorial

Building an Expense Tracker with Scala 3 - Part 4: Expenses & Categories

In this part, we will implement the core functionality of our application: managing Categories and Expenses. This involves creating CRUD (Create, Read, Update, Delete) operations.

We'll see how to model relationships between data (Expenses belong to Categories and Users) and how to expose these relationships via our API.

Models

We'll start by defining the data models. Notice how we use Option[Long] for IDs that are generated by the database.

// app/models/Category.scala
package models

case class Category(
  id: Option[Long],
  userId: Long, // Foreign key to User
  name: String,
  icon: String, // We'll store the icon name (e.g., "coffee") to render on the frontend
  color: String // Hex color code
)
// app/models/Expense.scala
package models

import java.util.Date

case class Expense(
  id: Option[Long],
  userId: Long,
  categoryId: Long, // Foreign key to Category
  amount: BigDecimal, // Always use BigDecimal for money! Double can have precision errors.
  currency: String,
  description: String,
  date: Date,
  createdAt: Date
)

Repositories

Next, we implement the repositories to handle database operations. We'll continue using our DatabaseService helper.

Category Repository

// app/repositories/CategoryRepository.scala
package repositories

import models.Category
import services.DatabaseService
import java.sql.{ResultSet, Statement}

class CategoryRepository(db: DatabaseService) {

  // Fetch all categories for a specific user
  def list(userId: Long): Seq[Category] = {
    db.withConnection { conn =>
      val stmt = conn.prepareStatement("SELECT * FROM categories WHERE user_id = ?")
      stmt.setLong(1, userId)
      val rs = stmt.executeQuery()
      
      // We iterate over the ResultSet and map each row to a Category object
      val categories = scala.collection.mutable.ListBuffer[Category]()
      while (rs.next()) {
        categories += mapRow(rs)
      }
      categories.toSeq
    }
  }

  def create(category: Category): Long = {
    // ... insert logic similar to User and Expense
  }
  
  private def mapRow(rs: ResultSet): Category = {
      Category(
          id = Some(rs.getLong("id")),
          userId = rs.getLong("user_id"),
          name = rs.getString("name"),
          icon = rs.getString("icon"),
          color = rs.getString("color")
      )
  }
}

Expense Repository

// app/repositories/ExpenseRepository.scala
package repositories

import models.Expense
import services.DatabaseService
// ... imports

class ExpenseRepository(db: DatabaseService) {
  // ... list and create methods similar to CategoryRepository
  // Remember to handle the BigDecimal conversion correctly with rs.getBigDecimal()
}

Controllers

Now we create the controllers to expose these operations via the API. We'll use Play's Json.format macro to automatically generate JSON serializers/deserializers for our case classes.

// app/controllers/ExpenseController.scala
package controllers

import models.Expense
import play.api.mvc.*
import play.api.libs.json.*
import repositories.ExpenseRepository
import javax.inject.Inject
import scala.concurrent.ExecutionContext

class ExpenseController(cc: ControllerComponents, expenseRepo: ExpenseRepository)(implicit ec: ExecutionContext) extends AbstractController(cc) {

  // This one line generates the JSON reader and writer for the Expense class!
  implicit val expenseFormat: Format[Expense] = Json.format[Expense]

  def list(userId: Long) = Action {
    val expenses = expenseRepo.list(userId)
    Ok(Json.toJson(expenses)) // Automatically converts Seq[Expense] to JSON array
  }

  def create(userId: Long) = Action(parse.json) { request =>
    // ... validation and creation logic
    // In a real app, verify that the `userId` in the path matches the authenticated user!
  }
}

Wiring and Routes

Finally, wire everything up in AppLoader.scala and add the routes.

// app/AppLoader.scala
lazy val categoryController: CategoryController = wire[CategoryController]
lazy val expenseController: ExpenseController = wire[ExpenseController]
lazy val categoryRepository: repositories.CategoryRepository = wire[repositories.CategoryRepository]
lazy val expenseRepository: repositories.ExpenseRepository = wire[repositories.ExpenseRepository]
# conf/routes
# We use :userId in the route to identify whose resources we are accessing
GET     /api/users/:userId/categories controllers.CategoryController.list(userId: Long)
POST    /api/users/:userId/categories controllers.CategoryController.create(userId: Long)

GET     /api/users/:userId/expenses   controllers.ExpenseController.list(userId: Long)
POST    /api/users/:userId/expenses   controllers.ExpenseController.create(userId: Long)

Conclusion

We now have a fully functional API for tracking expenses! We can create users, log in, manage categories, and track expenses.

In the next and final part of the backend series, we will add Internationalization and write some Tests to ensure our code is robust.

Continue to Part 5: Internationalization & Testing