Add Transaction routes

This commit is contained in:
William Brawner 2021-08-05 21:00:23 -06:00
parent f6e0157560
commit 9fc3d1ac1c
12 changed files with 212 additions and 288 deletions

View file

@ -10,6 +10,7 @@ import io.ktor.auth.*
import io.ktor.http.* import io.ktor.http.*
import io.ktor.response.* import io.ktor.response.*
import io.ktor.util.pipeline.* import io.ktor.util.pipeline.*
import java.time.Instant
suspend inline fun PipelineContext<Unit, ApplicationCall>.requireBudgetWithPermission( suspend inline fun PipelineContext<Unit, ApplicationCall>.requireBudgetWithPermission(
permissionRepository: PermissionRepository, permissionRepository: PermissionRepository,
@ -56,3 +57,5 @@ suspend inline fun PipelineContext<Unit, ApplicationCall>.errorResponse(
call.respond(httpStatusCode, ErrorResponse(message)) call.respond(httpStatusCode, ErrorResponse(message))
}?: call.respond(httpStatusCode) }?: call.respond(httpStatusCode)
} }
fun String.toInstant(): Instant = Instant.parse(this)

View file

@ -36,7 +36,7 @@ fun Application.budgetRoutes(
} }
} }
post("/{id}") { post("/") {
val session = call.principal<Session>()!! val session = call.principal<Session>()!!
val request = call.receive<BudgetRequest>() val request = call.receive<BudgetRequest>()
if (request.name.isNullOrBlank()) { if (request.name.isNullOrBlank()) {

View file

@ -41,7 +41,7 @@ fun Application.categoryRoutes(
).map { CategoryResponse(it) }) ).map { CategoryResponse(it) })
} }
post("/{id}") { post("/") {
val session = call.principal<Session>()!! val session = call.principal<Session>()!!
val request = call.receive<CategoryRequest>() val request = call.receive<CategoryRequest>()
if (request.title.isNullOrBlank()) { if (request.title.isNullOrBlank()) {

View file

@ -2,17 +2,7 @@ package com.wbrawner.twigs
import com.wbrawner.twigs.model.Transaction import com.wbrawner.twigs.model.Transaction
data class NewTransactionRequest( data class TransactionRequest(
val title: String,
val description: String? = null,
val date: String,
val amount: Long,
val categoryId: String? = null,
val expense: Boolean,
val budgetId: String
)
data class UpdateTransactionRequest(
val title: String? = null, val title: String? = null,
val description: String? = null, val description: String? = null,
val date: String? = null, val date: String? = null,
@ -20,7 +10,6 @@ data class UpdateTransactionRequest(
val categoryId: String? = null, val categoryId: String? = null,
val expense: Boolean? = null, val expense: Boolean? = null,
val budgetId: String? = null, val budgetId: String? = null,
val createdBy: String? = null
) )
data class TransactionResponse( data class TransactionResponse(
@ -33,16 +22,18 @@ data class TransactionResponse(
val budgetId: String, val budgetId: String,
val categoryId: String?, val categoryId: String?,
val createdBy: String val createdBy: String
) { )
constructor(transaction: Transaction) : this(
transaction.id, data class BalanceResponse(val balance: Long)
transaction.title,
transaction.description, fun Transaction.asResponse(): TransactionResponse = TransactionResponse(
transaction.date.toString(), id = id,
transaction.amount, title = title,
transaction.expense, description = description,
transaction.budget!!.id, date = date.toString(),
transaction.category?.id, amount = amount,
transaction.createdBy!!.id expense = expense,
) budgetId = budgetId,
} categoryId = categoryId,
createdBy = createdBy
)

View file

@ -0,0 +1,162 @@
package com.wbrawner.twigs
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.model.Transaction
import com.wbrawner.twigs.storage.PermissionRepository
import com.wbrawner.twigs.storage.Session
import com.wbrawner.twigs.storage.TransactionRepository
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.http.*
import io.ktor.request.*
import io.ktor.response.*
import io.ktor.routing.*
import java.time.Instant
fun Application.transactionRoutes(
transactionRepository: TransactionRepository,
permissionRepository: PermissionRepository
) {
routing {
route("/api/transactions") {
authenticate(optional = false) {
get("/") {
val session = call.principal<Session>()!!
call.respond(transactionRepository.findAll(
budgetIds = permissionRepository.findAll(
budgetIds = call.request.queryParameters.getAll("budgetIds"),
userId = session.userId
).map { it.budgetId },
categoryIds = call.request.queryParameters.getAll("categoryIds"),
from = call.request.queryParameters["from"]?.let { Instant.parse(it) },
to = call.request.queryParameters["to"]?.let { Instant.parse(it) },
expense = call.request.queryParameters["expense"]?.toBoolean(),
).map { it.asResponse() })
}
get("/{id}") {
val session = call.principal<Session>()!!
val transaction = transactionRepository.findAll(
ids = call.parameters.getAll("id"),
budgetIds = permissionRepository.findAll(
userId = session.userId
)
.map { it.budgetId }
)
.map { it.asResponse() }
.firstOrNull()
transaction?.let {
call.respond(it)
} ?: errorResponse()
}
get("/sum") {
val categoryId = call.request.queryParameters["categoryId"]
val budgetId = call.request.queryParameters["budgetId"]
val from = call.request.queryParameters["from"]?.toInstant() ?: firstOfMonth.toInstant()
val to = call.request.queryParameters["to"]?.toInstant() ?: endOfMonth.toInstant()
val balance = if (!categoryId.isNullOrBlank()) {
if (!budgetId.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "budgetId and categoryId cannot be provided together")
return@get
}
transactionRepository.sumByCategory(categoryId, from, to)
} else if (!budgetId.isNullOrBlank()) {
transactionRepository.sumByBudget(budgetId, from, to)
} else {
errorResponse(HttpStatusCode.BadRequest, "budgetId or categoryId must be provided to sum")
return@get
}
call.respond(BalanceResponse(balance))
}
post("/") {
val session = call.principal<Session>()!!
val request = call.receive<TransactionRequest>()
if (request.title.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "Title cannot be null or empty")
return@post
}
if (request.budgetId.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "Budget ID cannot be null or empty")
return@post
}
requireBudgetWithPermission(
permissionRepository,
session.userId,
request.budgetId,
Permission.WRITE
) {
return@post
}
call.respond(
transactionRepository.save(
Transaction(
title = request.title,
description = request.description,
amount = request.amount ?: 0L,
expense = request.expense ?: true,
budgetId = request.budgetId,
categoryId = request.categoryId,
createdBy = session.userId,
date = request.date?.let { Instant.parse(it) } ?: Instant.now()
)
).asResponse()
)
}
put("/{id}") {
val session = call.principal<Session>()!!
val request = call.receive<TransactionRequest>()
val transaction = transactionRepository.findAll(ids = call.parameters.getAll("id"))
.firstOrNull()
?: run {
errorResponse()
return@put
}
requireBudgetWithPermission(
permissionRepository,
session.userId,
transaction.budgetId,
Permission.WRITE
) {
return@put
}
call.respond(
transactionRepository.save(
Transaction(
title = request.title ?: transaction.title,
description = request.description ?: transaction.description,
amount = request.amount ?: transaction.amount,
expense = request.expense ?: transaction.expense,
date = request.date?.let { Instant.parse(it) } ?: transaction.date,
categoryId = request.categoryId ?: transaction.categoryId,
budgetId = request.budgetId ?: transaction.budgetId,
createdBy = transaction.createdBy,
)
).asResponse()
)
}
delete("/{id}") {
val session = call.principal<Session>()!!
val transaction = transactionRepository.findAll(ids = call.parameters.getAll("id"))
.firstOrNull()
?: run {
errorResponse()
return@delete
}
requireBudgetWithPermission(
permissionRepository,
session.userId,
transaction.budgetId,
Permission.WRITE
) {
return@delete
}
transactionRepository.delete(transaction)
}
}
}
}
}

View file

@ -1,10 +1,8 @@
package com.wbrawner.twigs.server package com.wbrawner.twigs.server
import com.wbrawner.twigs.budgetRoutes import com.wbrawner.twigs.*
import com.wbrawner.twigs.categoryRoutes import com.wbrawner.twigs.model.Transaction
import com.wbrawner.twigs.storage.* import com.wbrawner.twigs.storage.*
import com.wbrawner.twigs.twoWeeksFromNow
import com.wbrawner.twigs.userRoutes
import io.ktor.application.* import io.ktor.application.*
import io.ktor.auth.* import io.ktor.auth.*
import io.ktor.sessions.* import io.ktor.sessions.*
@ -23,6 +21,7 @@ fun Application.module(
categoryRepository: CategoryRepository, categoryRepository: CategoryRepository,
permissionRepository: PermissionRepository, permissionRepository: PermissionRepository,
sessionRepository: SessionRepository, sessionRepository: SessionRepository,
transactionRepository: TransactionRepository,
userRepository: UserRepository userRepository: UserRepository
) { ) {
install(Sessions) { install(Sessions) {
@ -43,6 +42,7 @@ fun Application.module(
} }
budgetRoutes(budgetRepository, permissionRepository) budgetRoutes(budgetRepository, permissionRepository)
categoryRoutes(categoryRepository, permissionRepository) categoryRoutes(categoryRepository, permissionRepository)
transactionRoutes(transactionRepository, permissionRepository)
userRoutes(permissionRepository, sessionRepository, userRepository) userRoutes(permissionRepository, sessionRepository, userRepository)
launch { launch {
while (currentCoroutineContext().isActive) { while (currentCoroutineContext().isActive) {

View file

@ -1,209 +0,0 @@
package com.wbrawner.twigs.server.transaction
import com.wbrawner.twigs.ErrorResponse
import com.wbrawner.twigs.server.category.Category
import com.wbrawner.twigs.server.category.CategoryRepository
import com.wbrawner.twigs.server.currentUser
import com.wbrawner.twigs.server.endOfMonth
import com.wbrawner.twigs.server.firstOfMonth
import com.wbrawner.twigs.server.permission.Permission
import com.wbrawner.twigs.server.permission.UserPermission
import com.wbrawner.twigs.server.permission.UserPermissionRepository
import org.slf4j.LoggerFactory
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import java.time.Instant
import java.util.stream.Collectors
import javax.transaction.Transactional
import kotlin.math.min
@RestController
@RequestMapping(path = ["/transactions"])
@Transactional
open class TransactionController(
private val categoryRepository: CategoryRepository,
private val transactionRepository: TransactionRepository,
private val userPermissionsRepository: UserPermissionRepository
) {
private val logger = LoggerFactory.getLogger(TransactionController::class.java)
@GetMapping(path = [""], produces = [MediaType.APPLICATION_JSON_VALUE])
open fun getTransactions(
@RequestParam(value = "categoryIds", required = false) categoryIds: List<String>?,
@RequestParam(value = "budgetIds", required = false) budgetIds: List<String>?,
@RequestParam(value = "from", required = false) from: String?,
@RequestParam(value = "to", required = false) to: String?,
@RequestParam(value = "count", required = false) count: Int?,
@RequestParam(value = "page", required = false) page: Int?,
@RequestParam(value = "sortBy", required = false) sortBy: String?,
@RequestParam(value = "sortOrder", required = false) sortOrder: Sort.Direction?
): ResponseEntity<List<TransactionResponse>> {
val userPermissions: List<UserPermission> = if (budgetIds != null && budgetIds.isNotEmpty()) {
userPermissionsRepository.findAllByUserAndBudget_IdIn(
currentUser,
budgetIds,
PageRequest.of(page ?: 0, count ?: 1000)
)
} else {
userPermissionsRepository.findAllByUser(currentUser, null)
}
val budgets = userPermissions.stream()
.map { obj: UserPermission -> obj.budget }
.collect(Collectors.toList())
var categories: List<Category?>? = null
if (categoryIds != null && categoryIds.isNotEmpty()) {
categories = categoryRepository.findAllByBudgetInAndIdIn(budgets, categoryIds, null)
}
val pageRequest = PageRequest.of(
min(0, if (page != null) page - 1 else 0),
count ?: 1000,
sortOrder ?: Sort.Direction.DESC,
sortBy ?: "date"
)
val fromInstant: Instant = try {
Instant.parse(from)
} catch (e: Exception) {
if (e !is NullPointerException) logger.error("Failed to parse '$from' to Instant for 'from' parameter", e)
firstOfMonth.toInstant()
}
val toInstant: Instant = try {
Instant.parse(to)
} catch (e: Exception) {
if (e !is NullPointerException) logger.error("Failed to parse '$to' to Instant for 'to' parameter", e)
endOfMonth.toInstant()
}
val query = if (categories == null) {
transactionRepository.findAllByBudgetInAndDateGreaterThanAndDateLessThan(
budgets,
fromInstant,
toInstant,
pageRequest
)
} else {
transactionRepository.findAllByBudgetInAndCategoryInAndDateGreaterThanAndDateLessThan(
budgets,
categories,
fromInstant,
toInstant,
pageRequest
)
}
val transactions = query.map { transaction: Transaction -> TransactionResponse(transaction) }
return ResponseEntity.ok(transactions)
}
@GetMapping(path = ["/{id}"], produces = [MediaType.APPLICATION_JSON_VALUE])
open fun getTransaction(@PathVariable id: String?): ResponseEntity<TransactionResponse> {
val budgets = userPermissionsRepository.findAllByUser(currentUser, null)
.stream()
.map { obj: UserPermission -> obj.budget }
.collect(Collectors.toList())
val transaction = transactionRepository.findByIdAndBudgetIn(id, budgets).orElse(null)
?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(TransactionResponse(transaction))
}
@PostMapping(
path = [""],
consumes = [MediaType.APPLICATION_JSON_VALUE],
produces = [MediaType.APPLICATION_JSON_VALUE]
)
open fun newTransaction(@RequestBody request: NewTransactionRequest): ResponseEntity<Any> {
val userResponse = userPermissionsRepository.findByUserAndBudget_Id(currentUser, request.budgetId)
.orElse(null) ?: return ResponseEntity.badRequest().body(ErrorResponse("Invalid budget ID"))
if (userResponse.permission.isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
}
val budget = userResponse.budget
var category: Category? = null
if (request.categoryId != null) {
category = categoryRepository.findByBudgetAndId(budget, request.categoryId).orElse(null)
}
return ResponseEntity.ok(
TransactionResponse(
transactionRepository.save(
Transaction(
title = request.title,
description = request.description,
date = Instant.parse(request.date),
amount = request.amount,
category = category,
expense = request.expense,
createdBy = currentUser,
budget = budget
)
)
)
)
}
@PutMapping(
path = ["/{id}"],
consumes = [MediaType.APPLICATION_JSON_VALUE],
produces = [MediaType.APPLICATION_JSON_VALUE]
)
open fun updateTransaction(
@PathVariable id: String,
@RequestBody request: UpdateTransactionRequest
): ResponseEntity<Any> {
val transaction = transactionRepository.findById(id).orElse(null)
?: return ResponseEntity.notFound().build()
userPermissionsRepository.findByUserAndBudget_Id(
currentUser,
transaction.budget!!.id
).orElse(null)
?: return ResponseEntity.notFound().build()
if (request.title != null) {
transaction.title = request.title
}
if (request.description != null) {
transaction.description = request.description
}
if (request.date != null) {
transaction.date = Instant.parse(request.date)
}
if (request.amount != null) {
transaction.amount = request.amount
}
if (request.expense != null) {
transaction.expense = request.expense
}
if (request.budgetId != null) {
val newUserPermission =
userPermissionsRepository.findByUserAndBudget_Id(currentUser, request.budgetId).orElse(null)
if (newUserPermission == null || newUserPermission.permission.isNotAtLeast(Permission.WRITE)) {
return ResponseEntity
.badRequest()
.body(ErrorResponse("Invalid budget"))
}
transaction.budget = newUserPermission.budget
}
if (request.categoryId != null) {
val category = categoryRepository.findByBudgetAndId(transaction.budget, request.categoryId).orElse(null)
?: return ResponseEntity
.badRequest()
.body(ErrorResponse("Invalid category"))
transaction.category = category
}
return ResponseEntity.ok(TransactionResponse(transactionRepository.save(transaction)))
}
@DeleteMapping(path = ["/{id}"], produces = [MediaType.TEXT_PLAIN_VALUE])
open fun deleteTransaction(@PathVariable id: String): ResponseEntity<Void> {
val transaction = transactionRepository.findById(id).orElse(null) ?: return ResponseEntity.notFound().build()
// Check that the transaction belongs to an budget that the user has access to before deleting it
val userPermission = userPermissionsRepository.findByUserAndBudget_Id(
currentUser,
transaction.budget!!.id
).orElse(null)
?: return ResponseEntity.notFound().build()
if (userPermission.permission.isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
}
transactionRepository.delete(transaction)
return ResponseEntity.ok().build()
}
}

View file

@ -1,41 +0,0 @@
package com.wbrawner.twigs.server.transaction
import com.wbrawner.twigs.server.budget.Budget
import com.wbrawner.twigs.server.category.Category
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.PagingAndSortingRepository
import java.time.Instant
import java.util.*
interface TransactionRepository : PagingAndSortingRepository<Transaction, String> {
fun findByIdAndBudgetIn(id: String?, budgets: List<Budget?>?): Optional<Transaction>
fun findAllByBudgetInAndCategoryInAndDateGreaterThanAndDateLessThan(
budgets: List<Budget?>?,
categories: List<Category?>?,
start: Instant?,
end: Instant?,
pageable: Pageable?
): List<Transaction>
fun findAllByBudgetInAndDateGreaterThanAndDateLessThan(
budgets: List<Budget?>?,
start: Instant?,
end: Instant?,
pageable: Pageable?
): List<Transaction>
fun findAllByBudgetAndCategory(budget: Budget?, category: Category?): List<Transaction>
@Query(
nativeQuery = true,
value = "SELECT (COALESCE((SELECT SUM(amount) from transaction WHERE Budget_id = :BudgetId AND expense = 0 AND date >= :from AND date <= :to), 0)) - (COALESCE((SELECT SUM(amount) from transaction WHERE Budget_id = :BudgetId AND expense = 1 AND date >= :from AND date <= :to), 0));"
)
fun sumBalanceByBudgetId(BudgetId: String?, from: Instant?, to: Instant?): Long
@Query(
nativeQuery = true,
value = "SELECT (COALESCE((SELECT SUM(amount) from transaction WHERE category_id = :categoryId AND expense = 0 AND date > :start), 0)) - (COALESCE((SELECT SUM(amount) from transaction WHERE category_id = :categoryId AND expense = 1 AND date > :start), 0));"
)
fun sumBalanceByCategoryId(categoryId: String?, start: Date?): Long
}

View file

@ -11,7 +11,7 @@ private val CALENDAR_FIELDS = intArrayOf(
) )
val firstOfMonth: Date val firstOfMonth: Date
get() = GregorianCalendar().run { get() = GregorianCalendar(TimeZone.getTimeZone("UTC")).run {
for (calField in CALENDAR_FIELDS) { for (calField in CALENDAR_FIELDS) {
set(calField, getActualMinimum(calField)) set(calField, getActualMinimum(calField))
} }
@ -19,7 +19,7 @@ val firstOfMonth: Date
} }
val endOfMonth: Date val endOfMonth: Date
get() = GregorianCalendar().run { get() = GregorianCalendar(TimeZone.getTimeZone("UTC")).run {
for (calField in CALENDAR_FIELDS) { for (calField in CALENDAR_FIELDS) {
set(calField, getActualMaximum(calField)) set(calField, getActualMaximum(calField))
} }
@ -27,7 +27,7 @@ val endOfMonth: Date
} }
val twoWeeksFromNow: Date val twoWeeksFromNow: Date
get() = GregorianCalendar().run { get() = GregorianCalendar(TimeZone.getTimeZone("UTC")).run {
add(Calendar.DATE, 14) add(Calendar.DATE, 14)
time time
} }

View file

@ -9,8 +9,8 @@ data class Transaction(
val description: String? = null, val description: String? = null,
val date: Instant? = null, val date: Instant? = null,
val amount: Long? = null, val amount: Long? = null,
val category: Category? = null, val categoryId: String? = null,
val expense: Boolean? = null, val expense: Boolean? = null,
val createdBy: User? = null, val createdBy: String,
val budget: Budget? = null val budgetId: String
) )

View file

@ -11,4 +11,3 @@ data class Session(
val token: String = randomString(255), val token: String = randomString(255),
var expiration: Date = twoWeeksFromNow var expiration: Date = twoWeeksFromNow
) : Principal ) : Principal

View file

@ -0,0 +1,19 @@
package com.wbrawner.twigs.storage
import com.wbrawner.twigs.model.Transaction
import java.time.Instant
interface TransactionRepository : Repository<Transaction> {
fun findAll(
ids: List<String>? = null,
budgetIds: List<String>? = null,
categoryIds: List<String>? = null,
expense: Boolean? = null,
from: Instant? = null,
to: Instant? = null,
): List<Transaction>
fun sumByBudget(budgetId: String, from: Instant, to: Instant): Long
fun sumByCategory(categoryId: String, from: Instant, to: Instant): Long
}