WIP: Implement recurring transactions
This commit is contained in:
parent
5d26ee9af3
commit
1ab9af9d17
16 changed files with 947 additions and 4 deletions
|
@ -0,0 +1,171 @@
|
|||
package com.wbrawner.twigs
|
||||
|
||||
import com.wbrawner.twigs.model.Permission
|
||||
import com.wbrawner.twigs.model.Session
|
||||
import com.wbrawner.twigs.model.Transaction
|
||||
import com.wbrawner.twigs.storage.PermissionRepository
|
||||
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.recurringTransactionRoutes(
|
||||
transactionRepository: TransactionRepository,
|
||||
permissionRepository: PermissionRepository
|
||||
) {
|
||||
routing {
|
||||
route("/api/recurringtransactions") {
|
||||
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
|
||||
val to = call.request.queryParameters["to"]?.toInstant() ?: endOfMonth
|
||||
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.copy(
|
||||
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
|
||||
}
|
||||
val response = if (transactionRepository.delete(transaction)) {
|
||||
HttpStatusCode.NoContent
|
||||
} else {
|
||||
HttpStatusCode.InternalServerError
|
||||
}
|
||||
call.respond(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
package com.wbrawner.twigs
|
||||
|
||||
import com.wbrawner.twigs.model.RecurringTransaction
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class RecurringTransactionRequest(
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
val amount: Long? = null,
|
||||
val categoryId: String? = null,
|
||||
val expense: Boolean? = null,
|
||||
val budgetId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RecurringTransactionResponse(
|
||||
val id: String,
|
||||
val title: String?,
|
||||
val description: String?,
|
||||
// val frequency: FrequencyResponse,
|
||||
val amount: Long?,
|
||||
val expense: Boolean?,
|
||||
val budgetId: String,
|
||||
val categoryId: String?,
|
||||
val createdBy: String
|
||||
)
|
||||
|
||||
fun RecurringTransaction.asResponse(): RecurringTransactionResponse = RecurringTransactionResponse(
|
||||
id = id,
|
||||
title = title,
|
||||
description = description,
|
||||
// frequency = date.toString(),
|
||||
amount = amount,
|
||||
expense = expense,
|
||||
budgetId = budgetId,
|
||||
categoryId = categoryId,
|
||||
createdBy = createdBy
|
||||
)
|
|
@ -31,6 +31,9 @@ dependencies {
|
|||
implementation("io.ktor:ktor-server-sessions:$ktorVersion")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0")
|
||||
implementation("ch.qos.logback:logback-classic:1.2.3")
|
||||
testImplementation(project(":testhelpers"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter-api:5.6.0")
|
||||
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
|
||||
}
|
||||
|
||||
description = "twigs-server"
|
||||
|
@ -99,3 +102,7 @@ tasks.register("publish") {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.getByName<Test>("test") {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
|
|
@ -44,6 +44,7 @@ fun Application.module() {
|
|||
budgetRepository = JdbcBudgetRepository(it),
|
||||
categoryRepository = JdbcCategoryRepository(it),
|
||||
permissionRepository = JdbcPermissionRepository(it),
|
||||
// recurringTransactionRepository = Fa,
|
||||
sessionRepository = JdbcSessionRepository(it),
|
||||
transactionRepository = JdbcTransactionRepository(it),
|
||||
userRepository = JdbcUserRepository(it)
|
||||
|
@ -57,6 +58,7 @@ fun Application.moduleWithDependencies(
|
|||
budgetRepository: BudgetRepository,
|
||||
categoryRepository: CategoryRepository,
|
||||
permissionRepository: PermissionRepository,
|
||||
// recurringTransactionRepository: RecurringTransactionRepository,
|
||||
sessionRepository: SessionRepository,
|
||||
transactionRepository: TransactionRepository,
|
||||
userRepository: UserRepository
|
||||
|
@ -130,9 +132,17 @@ fun Application.moduleWithDependencies(
|
|||
)
|
||||
).salt
|
||||
}
|
||||
val jobs = listOf(
|
||||
SessionCleanupJob(sessionRepository),
|
||||
// RecurringTransactionProcessingJob(recurringTransactionRepository, transactionRepository)
|
||||
)
|
||||
while (currentCoroutineContext().isActive) {
|
||||
delay(Duration.hours(24))
|
||||
sessionRepository.deleteExpired()
|
||||
jobs.forEach { it.run() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Job {
|
||||
suspend fun run()
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
package com.wbrawner.twigs.server
|
||||
|
||||
import com.wbrawner.twigs.model.Frequency
|
||||
import com.wbrawner.twigs.model.Position
|
||||
import com.wbrawner.twigs.storage.RecurringTransactionRepository
|
||||
import com.wbrawner.twigs.storage.TransactionRepository
|
||||
import java.time.*
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.*
|
||||
import kotlin.math.ceil
|
||||
|
||||
class RecurringTransactionProcessingJob(
|
||||
private val recurringTransactionRepository: RecurringTransactionRepository,
|
||||
private val transactionRepository: TransactionRepository
|
||||
) : Job {
|
||||
override suspend fun run() {
|
||||
val now = Instant.now()
|
||||
val maxDaysInMonth = GregorianCalendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
.getActualMaximum(Calendar.DAY_OF_MONTH)
|
||||
createTransactions(now, maxDaysInMonth)
|
||||
}
|
||||
|
||||
suspend fun createTransactions(now: Instant, maxDaysInMonth: Int) {
|
||||
recurringTransactionRepository.findAll(now).forEach {
|
||||
val zonedNow = now.atZone(ZoneId.of("UTC"))
|
||||
when (it.frequency) {
|
||||
is Frequency.Daily -> {
|
||||
if (it.lastRun != null && ChronoUnit.DAYS.between(it.lastRun, now) < it.frequency.count)
|
||||
return@forEach
|
||||
}
|
||||
is Frequency.Weekly -> {
|
||||
it.lastRun?.let { last ->
|
||||
val zonedLastRun = last.atZone(ZoneId.of("UTC"))
|
||||
if (ChronoUnit.WEEKS.between(zonedLastRun, zonedNow) < it.frequency.count)
|
||||
return@forEach
|
||||
}
|
||||
if (!(it.frequency as Frequency.Weekly).daysOfWeek.contains(DayOfWeek.from(zonedNow)))
|
||||
return@forEach
|
||||
}
|
||||
is Frequency.Monthly -> {
|
||||
it.lastRun?.let { last ->
|
||||
val zonedLastRun = last.atZone(ZoneId.of("UTC"))
|
||||
if (zonedNow.monthValue - zonedLastRun.monthValue < it.frequency.count)
|
||||
return@forEach
|
||||
}
|
||||
val frequency = (it.frequency as Frequency.Monthly).dayOfMonth
|
||||
frequency.day?.let { day ->
|
||||
if (zonedNow.dayOfMonth != Integer.min(day, maxDaysInMonth))
|
||||
return@forEach
|
||||
}
|
||||
frequency.positionalDayOfWeek?.let { positionalDayOfWeek ->
|
||||
if (positionalDayOfWeek.dayOfWeek != DayOfWeek.from(now.atZone(ZoneId.of("UTC"))))
|
||||
return@forEach
|
||||
val dayOfMonth = now.atZone(ZoneId.of("UTC")).dayOfMonth
|
||||
val position = ceil(dayOfMonth / 7.0).toInt()
|
||||
when (positionalDayOfWeek.position) {
|
||||
Position.FIRST -> if (position != 1) return@forEach
|
||||
Position.SECOND -> if (position != 2) return@forEach
|
||||
Position.THIRD -> if (position != 3) return@forEach
|
||||
Position.FOURTH -> if (position != 4) return@forEach
|
||||
Position.LAST -> {
|
||||
if (dayOfMonth + 7 <= maxDaysInMonth)
|
||||
return@forEach
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
is Frequency.Yearly -> {
|
||||
it.lastRun?.let { last ->
|
||||
val zonedLastRun = last.atZone(ZoneId.of("UTC"))
|
||||
if (zonedNow.year - zonedLastRun.year < it.frequency.count)
|
||||
return@forEach
|
||||
}
|
||||
with((it.frequency as Frequency.Yearly).dayOfYear) {
|
||||
// If the user has selected Feb 29th, then on non-leap years we'll adjust the date to Feb 28th
|
||||
val adjustedMonthDay =
|
||||
if (this.month == Month.FEBRUARY && this.dayOfMonth == 29 && !Year.isLeap(zonedNow.year.toLong())) {
|
||||
MonthDay.of(2, 28)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
if (MonthDay.from(zonedNow) != adjustedMonthDay)
|
||||
return@forEach
|
||||
}
|
||||
}
|
||||
}
|
||||
transactionRepository.save(it.toTransaction(now))
|
||||
recurringTransactionRepository.save(it.copy(lastRun = now))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package com.wbrawner.twigs.server
|
||||
|
||||
import com.wbrawner.twigs.storage.SessionRepository
|
||||
|
||||
class SessionCleanupJob(private val sessionRepository: SessionRepository) : Job {
|
||||
override suspend fun run() {
|
||||
sessionRepository.deleteExpired()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,308 @@
|
|||
package com.wbrawner.twigs.server
|
||||
|
||||
import com.wbrawner.twigs.model.*
|
||||
import com.wbrawner.twigs.storage.RecurringTransactionRepository
|
||||
import com.wbrawner.twigs.storage.TransactionRepository
|
||||
import com.wbrawner.twigs.test.helpers.repository.FakeRecurringTransactionsRepository
|
||||
import com.wbrawner.twigs.test.helpers.repository.FakeTransactionRepository
|
||||
import kotlinx.coroutines.test.runBlockingTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.*
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.*
|
||||
|
||||
class RecurringTransactionProcessingJobTest {
|
||||
private lateinit var recurringTransactionRepository: RecurringTransactionRepository
|
||||
private lateinit var transactionRepository: TransactionRepository
|
||||
private lateinit var job: RecurringTransactionProcessingJob
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
recurringTransactionRepository = FakeRecurringTransactionsRepository()
|
||||
transactionRepository = FakeTransactionRepository()
|
||||
job = RecurringTransactionProcessingJob(recurringTransactionRepository, transactionRepository)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `daily transactions are created every day`() = runBlockingTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Daily transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Daily(1, Time(9, 0, 0, 0)),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopForDays(start, 3)
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(3, createdTransactions.size)
|
||||
assertEquals("1970-01-01T09:00:00Z", createdTransactions[0].date.toString())
|
||||
assertEquals("1970-01-02T09:00:00Z", createdTransactions[1].date.toString())
|
||||
assertEquals("1970-01-03T09:00:00Z", createdTransactions[2].date.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `daily transactions are created every other day`() = runBlockingTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Daily transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Daily(2, Time(9, 0, 0, 0)),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopForDays(start, 3)
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(2, createdTransactions.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weekly transactions are created every thursday`() = runBlockingTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Weekly transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Weekly(1, setOf(DayOfWeek.THURSDAY), Time(9, 0, 0, 0)),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopForDays(start, 28)
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(4, createdTransactions.size)
|
||||
assertEquals("1970-01-01T09:00:00Z", createdTransactions[0].date.toString())
|
||||
assertEquals("1970-01-08T09:00:00Z", createdTransactions[1].date.toString())
|
||||
assertEquals("1970-01-15T09:00:00Z", createdTransactions[2].date.toString())
|
||||
assertEquals("1970-01-22T09:00:00Z", createdTransactions[3].date.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weekly transactions are created every third thursday`() = runBlockingTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Weekly transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Weekly(3, setOf(DayOfWeek.THURSDAY), Time(9, 0, 0, 0)),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopForDays(start, 28)
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(2, createdTransactions.size)
|
||||
assertEquals("1970-01-01T09:00:00Z", createdTransactions[0].date.toString())
|
||||
assertEquals("1970-01-22T09:00:00Z", createdTransactions[1].date.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `monthly transactions are created every 1st of month`() = runBlockingTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Monthly transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Monthly(1, DayOfMonth.day(1), Time(9, 0, 0, 0)),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopForDays(start, 90)
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(3, createdTransactions.size)
|
||||
assertEquals("1970-01-01T09:00:00Z", createdTransactions[0].date.toString())
|
||||
assertEquals("1970-02-01T09:00:00Z", createdTransactions[1].date.toString())
|
||||
assertEquals("1970-03-01T09:00:00Z", createdTransactions[2].date.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `monthly transactions are created every last day of month when greater than max days in month`() =
|
||||
runBlockingTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Monthly transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Monthly(1, DayOfMonth.day(31), Time(9, 0, 0, 0)),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopForDays(start, 120)
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(4, createdTransactions.size)
|
||||
assertEquals("1970-01-31T09:00:00Z", createdTransactions[0].date.toString())
|
||||
assertEquals("1970-02-28T09:00:00Z", createdTransactions[1].date.toString())
|
||||
assertEquals("1970-03-31T09:00:00Z", createdTransactions[2].date.toString())
|
||||
assertEquals("1970-04-30T09:00:00Z", createdTransactions[3].date.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `monthly transactions are created every 6 months`() = runBlockingTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Monthly transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Monthly(6, DayOfMonth.day(15), Time(9, 0, 0, 0)),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopForDays(start, 197)
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(2, createdTransactions.size)
|
||||
assertEquals("1970-01-15T09:00:00Z", createdTransactions[0].date.toString())
|
||||
assertEquals("1970-07-15T09:00:00Z", createdTransactions[1].date.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `monthly transactions are created every 2nd tuesday`() = runBlockingTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Monthly transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Monthly(
|
||||
1,
|
||||
DayOfMonth.positionalDayOfWeek(Position.SECOND, DayOfWeek.TUESDAY),
|
||||
Time(9, 0, 0, 0)
|
||||
),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopForDays(start, 120)
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(4, createdTransactions.size)
|
||||
assertEquals("1970-01-13T09:00:00Z", createdTransactions[0].date.toString())
|
||||
assertEquals("1970-02-10T09:00:00Z", createdTransactions[1].date.toString())
|
||||
assertEquals("1970-03-10T09:00:00Z", createdTransactions[2].date.toString())
|
||||
assertEquals("1970-04-14T09:00:00Z", createdTransactions[3].date.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `monthly transactions are created every last friday`() = runBlockingTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Monthly transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Monthly(
|
||||
1,
|
||||
DayOfMonth.positionalDayOfWeek(Position.LAST, DayOfWeek.FRIDAY),
|
||||
Time(9, 0, 0, 0)
|
||||
),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopForDays(start, 120)
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(4, createdTransactions.size)
|
||||
assertEquals("1970-01-30T09:00:00Z", createdTransactions[0].date.toString())
|
||||
assertEquals("1970-02-27T09:00:00Z", createdTransactions[1].date.toString())
|
||||
assertEquals("1970-03-27T09:00:00Z", createdTransactions[2].date.toString())
|
||||
assertEquals("1970-04-24T09:00:00Z", createdTransactions[3].date.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yearly transactions are created every march 31st`() = runBlockingTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Yearly transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Yearly(1, MonthDay.of(3, 31), Time(9, 0, 0, 0)),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopForDays(start, 1460) // 4 years from Jan 1, 1970
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(4, createdTransactions.size)
|
||||
assertEquals("1970-03-31T09:00:00Z", createdTransactions[0].date.toString())
|
||||
assertEquals("1971-03-31T09:00:00Z", createdTransactions[1].date.toString())
|
||||
assertEquals("1972-03-31T09:00:00Z", createdTransactions[2].date.toString()) // 1972 was a leap year
|
||||
assertEquals("1973-03-31T09:00:00Z", createdTransactions[3].date.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yearly transactions are created every other march 31st`() = runBlockingTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Yearly transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Yearly(2, MonthDay.of(3, 31), Time(9, 0, 0, 0)),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopForDays(start, 1460) // 4 years from Jan 1, 1970
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(2, createdTransactions.size)
|
||||
assertEquals("1970-03-31T09:00:00Z", createdTransactions[0].date.toString())
|
||||
assertEquals("1972-03-31T09:00:00Z", createdTransactions[1].date.toString()) // 1972 was a leap year
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yearly transactions are created every february 29th`() = runBlockingTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Yearly transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Yearly(1, MonthDay.of(2, 29), Time(9, 0, 0, 0)),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopForDays(start, 1460) // 4 years from Jan 1, 1970
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(4, createdTransactions.size)
|
||||
assertEquals("1970-02-28T09:00:00Z", createdTransactions[0].date.toString())
|
||||
assertEquals("1971-02-28T09:00:00Z", createdTransactions[1].date.toString())
|
||||
assertEquals("1972-02-29T09:00:00Z", createdTransactions[2].date.toString()) // 1972 was a leap year
|
||||
assertEquals("1973-02-28T09:00:00Z", createdTransactions[3].date.toString())
|
||||
}
|
||||
|
||||
private suspend fun loopForDays(start: Instant, days: Int) {
|
||||
if (days == 0) return
|
||||
val maxDays = GregorianCalendar.from(ZonedDateTime.ofInstant(start, ZoneId.of("UTC")))
|
||||
.getActualMaximum(Calendar.DAY_OF_MONTH)
|
||||
job.createTransactions(start, maxDays)
|
||||
loopForDays(start.plus(1, ChronoUnit.DAYS), days - 1)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,207 @@
|
|||
package com.wbrawner.twigs.model
|
||||
|
||||
import com.wbrawner.twigs.Identifiable
|
||||
import com.wbrawner.twigs.randomString
|
||||
import java.time.DayOfWeek
|
||||
import java.time.Instant
|
||||
import java.time.MonthDay
|
||||
|
||||
data class RecurringTransaction(
|
||||
override val id: String = randomString(),
|
||||
val title: String,
|
||||
val description: String? = null,
|
||||
val frequency: Frequency,
|
||||
val start: Instant,
|
||||
val end: Instant? = null,
|
||||
val amount: Long,
|
||||
val expense: Boolean,
|
||||
val createdBy: String,
|
||||
val categoryId: String? = null,
|
||||
val budgetId: String,
|
||||
val lastRun: Instant? = null
|
||||
) : Identifiable {
|
||||
fun toTransaction(now: Instant = Instant.now()): Transaction = Transaction(
|
||||
title = title,
|
||||
description = description,
|
||||
date = frequency.instant(now),
|
||||
amount = amount,
|
||||
expense = expense,
|
||||
createdBy = createdBy,
|
||||
categoryId = categoryId,
|
||||
budgetId = budgetId
|
||||
)
|
||||
}
|
||||
|
||||
sealed class Frequency {
|
||||
abstract val count: Int
|
||||
abstract val time: Time
|
||||
|
||||
data class Daily(override val count: Int, override val time: Time) : Frequency() {
|
||||
companion object {
|
||||
fun parse(s: String): Daily {
|
||||
require(s[0] == 'D') { "Invalid format for Daily: $s" }
|
||||
return with(s.split(';')) {
|
||||
Daily(
|
||||
get(1).toInt(),
|
||||
Time.parse(get(2))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Weekly(override val count: Int, val daysOfWeek: Set<DayOfWeek>, override val time: Time) : Frequency() {
|
||||
companion object {
|
||||
fun parse(s: String): Weekly {
|
||||
require(s[0] == 'W') { "Invalid format for Weekly: $s" }
|
||||
return with(s.split(';')) {
|
||||
Weekly(
|
||||
get(1).toInt(),
|
||||
get(2).split(',').map { DayOfWeek.valueOf(it) }.toSet(),
|
||||
Time.parse(get(3))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Monthly(
|
||||
override val count: Int,
|
||||
val dayOfMonth: DayOfMonth,
|
||||
override val time: Time
|
||||
) : Frequency() {
|
||||
companion object {
|
||||
fun parse(s: String): Monthly {
|
||||
require(s[0] == 'M') { "Invalid format for Monthly: $s" }
|
||||
return with(s.split(';')) {
|
||||
Monthly(
|
||||
get(1).toInt(),
|
||||
DayOfMonth.parse(get(2)),
|
||||
Time.parse(get(3))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Yearly(override val count: Int, val dayOfYear: MonthDay, override val time: Time) : Frequency() {
|
||||
companion object {
|
||||
fun parse(s: String): Yearly {
|
||||
require(s[0] == 'Y') { "Invalid format for Yearly: $s" }
|
||||
return with(s.split(';')) {
|
||||
Yearly(
|
||||
get(1).toInt(),
|
||||
MonthDay.parse(get(2)),
|
||||
Time.parse(get(3))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun instant(now: Instant): Instant = Instant.parse(now.toString().split("T")[0] + "T" + time.toString() + "Z")
|
||||
|
||||
override fun toString(): String = when (this) {
|
||||
is Daily -> "D;$count;$time"
|
||||
is Weekly -> "W;$count;${daysOfWeek.joinToString(",")};$time"
|
||||
is Monthly -> "M;$count;$dayOfMonth;$time"
|
||||
is Yearly -> "Y;$count;$dayOfYear;$time"
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun parse(s: String): Frequency = when (s[0]) {
|
||||
'D' -> Daily.parse(s)
|
||||
'W' -> Weekly.parse(s)
|
||||
'M' -> Monthly.parse(s)
|
||||
'Y' -> Yearly.parse(s)
|
||||
else -> throw IllegalArgumentException("Invalid frequency format: $s")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Time(val hours: Int, val minutes: Int, val seconds: Int, val milliseconds: Int) {
|
||||
override fun toString(): String {
|
||||
val s = StringBuilder()
|
||||
if (hours < 10) {
|
||||
s.append("0")
|
||||
}
|
||||
s.append(hours)
|
||||
s.append(":")
|
||||
if (minutes < 10) {
|
||||
s.append("0")
|
||||
}
|
||||
s.append(minutes)
|
||||
s.append(":")
|
||||
if (seconds < 10) {
|
||||
s.append("0")
|
||||
}
|
||||
s.append(seconds)
|
||||
s.append(".")
|
||||
if (milliseconds < 100) {
|
||||
s.append("0")
|
||||
}
|
||||
if (milliseconds < 10) {
|
||||
s.append("0")
|
||||
}
|
||||
s.append(milliseconds)
|
||||
return s.toString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun parse(s: String): Time {
|
||||
require(s.length < 12) { "Invalid time format: $s. Time should be formatted as HH:mm:ss.SSS" }
|
||||
require(s[3] == ':') { "Invalid time format: $s. Time should be formatted as HH:mm:ss.SSS" }
|
||||
require(s[6] == ':') { "Invalid time format: $s. Time should be formatted as HH:mm:ss.SSS" }
|
||||
require(s[9] == '.') { "Invalid time format: $s. Time should be formatted as HH:mm:ss.SSS" }
|
||||
return Time(
|
||||
s.substring(0, 3).toInt(),
|
||||
s.substring(4, 6).toInt(),
|
||||
s.substring(7, 9).toInt(),
|
||||
s.substring(10).toInt()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DayOfMonth private constructor(
|
||||
val day: Int? = null,
|
||||
val positionalDayOfWeek: PositionalDayOfWeek? = null
|
||||
) {
|
||||
override fun toString() = day?.let { "DAY-${it}" } ?: positionalDayOfWeek!!.toString()
|
||||
|
||||
companion object {
|
||||
fun day(day: Int): DayOfMonth {
|
||||
require(day in 1..31) { "Day out of range: $day" }
|
||||
return DayOfMonth(day = day)
|
||||
}
|
||||
|
||||
fun positionalDayOfWeek(position: Position, dayOfWeek: DayOfWeek): DayOfMonth {
|
||||
return DayOfMonth(positionalDayOfWeek = PositionalDayOfWeek(position, dayOfWeek))
|
||||
}
|
||||
|
||||
fun parse(s: String): DayOfMonth = with(s.split("-")) {
|
||||
when (size) {
|
||||
2 -> when (first()) {
|
||||
"DAY" -> day(get(1).toInt())
|
||||
else -> positionalDayOfWeek(
|
||||
Position.valueOf(first()),
|
||||
DayOfWeek.valueOf(get(1))
|
||||
)
|
||||
}
|
||||
else -> throw IllegalArgumentException("Failed to parse string $s")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class PositionalDayOfWeek(val position: Position, val dayOfWeek: DayOfWeek) {
|
||||
override fun toString(): String = "${position.name}-${dayOfWeek.name}"
|
||||
}
|
||||
}
|
||||
|
||||
enum class Position {
|
||||
FIRST,
|
||||
SECOND,
|
||||
THIRD,
|
||||
FOURTH,
|
||||
LAST
|
||||
}
|
|
@ -1,2 +1,3 @@
|
|||
rootProject.name = "twigs"
|
||||
include("core", "api", "app", "storage", "db", "web")
|
||||
include("testhelpers")
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
package com.wbrawner.twigs.storage
|
||||
|
||||
import com.wbrawner.twigs.model.RecurringTransaction
|
||||
import java.time.Instant
|
||||
|
||||
interface RecurringTransactionRepository : Repository<RecurringTransaction> {
|
||||
suspend fun findAll(now: Instant): List<RecurringTransaction>
|
||||
}
|
1
testhelpers/.gitignore
vendored
Normal file
1
testhelpers/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
build/
|
12
testhelpers/build.gradle.kts
Normal file
12
testhelpers/build.gradle.kts
Normal file
|
@ -0,0 +1,12 @@
|
|||
plugins {
|
||||
kotlin("jvm")
|
||||
java
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(kotlin("stdlib"))
|
||||
implementation(project(":storage"))
|
||||
api("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.1")
|
||||
api("org.junit.jupiter:junit-jupiter-api:5.6.0")
|
||||
api("org.junit.jupiter:junit-jupiter-engine")
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package com.wbrawner.twigs.test.helpers.repository
|
||||
|
||||
import com.wbrawner.twigs.model.RecurringTransaction
|
||||
import com.wbrawner.twigs.storage.RecurringTransactionRepository
|
||||
import java.time.Instant
|
||||
|
||||
class FakeRecurringTransactionsRepository : FakeRepository<RecurringTransaction>(), RecurringTransactionRepository {
|
||||
override suspend fun findAll(now: Instant): List<RecurringTransaction> = entities.filter {
|
||||
(it.start == now || it.start.isBefore(now)) && it.end?.isAfter(now) ?: true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package com.wbrawner.twigs.test.helpers.repository
|
||||
|
||||
import com.wbrawner.twigs.Identifiable
|
||||
import com.wbrawner.twigs.storage.Repository
|
||||
|
||||
abstract class FakeRepository<T : Identifiable> : Repository<T> {
|
||||
val entities = mutableListOf<T>()
|
||||
|
||||
override suspend fun findAll(ids: List<String>?): List<T> = if (ids == null) {
|
||||
entities
|
||||
} else {
|
||||
entities.filter { ids.contains(it.id) }
|
||||
}
|
||||
|
||||
override suspend fun save(item: T): T {
|
||||
entities.removeIf { it.id == item.id }
|
||||
entities.add(item)
|
||||
return item
|
||||
}
|
||||
|
||||
override suspend fun delete(item: T): Boolean = entities.removeIf { it.id == item.id }
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package com.wbrawner.twigs.test.helpers.repository
|
||||
|
||||
import com.wbrawner.twigs.model.Transaction
|
||||
import com.wbrawner.twigs.storage.TransactionRepository
|
||||
import java.time.Instant
|
||||
|
||||
class FakeTransactionRepository : FakeRepository<Transaction>(), TransactionRepository {
|
||||
override fun findAll(
|
||||
ids: List<String>?,
|
||||
budgetIds: List<String>?,
|
||||
categoryIds: List<String>?,
|
||||
expense: Boolean?,
|
||||
from: Instant?,
|
||||
to: Instant?
|
||||
): List<Transaction> = entities.filter { transaction ->
|
||||
ids?.contains(transaction.id) ?: true
|
||||
&& budgetIds?.contains(transaction.budgetId) ?: true
|
||||
&& categoryIds?.contains(transaction.categoryId) ?: true
|
||||
&& expense?.let { it == transaction.expense } ?: true
|
||||
&& from?.isBefore(transaction.date) ?: true
|
||||
&& to?.isAfter(transaction.date) ?: true
|
||||
}
|
||||
|
||||
override fun sumByBudget(budgetId: String, from: Instant, to: Instant): Long = entities.asSequence()
|
||||
.filter {
|
||||
it.budgetId == budgetId
|
||||
&& from.isBefore(it.date)
|
||||
&& to.isAfter(it.date)
|
||||
}
|
||||
.sumOf {
|
||||
val modifier = if (it.expense) -1 else 1
|
||||
it.amount * modifier
|
||||
}
|
||||
|
||||
override fun sumByCategory(categoryId: String, from: Instant, to: Instant): Long = entities.asSequence()
|
||||
.filter {
|
||||
it.categoryId == categoryId
|
||||
&& from.isBefore(it.date)
|
||||
&& to.isAfter(it.date)
|
||||
}
|
||||
.sumOf {
|
||||
val modifier = if (it.expense) -1 else 1
|
||||
it.amount * modifier
|
||||
}
|
||||
}
|
|
@ -48,6 +48,6 @@ tasks.register("package") {
|
|||
}
|
||||
}
|
||||
|
||||
tasks.getByName("processResources") {
|
||||
dependsOn.add("package")
|
||||
}
|
||||
//tasks.getByName("processResources") {
|
||||
// dependsOn.add("package")
|
||||
//}
|
||||
|
|
Loading…
Reference in a new issue