WIP: Implement recurring transactions

This commit is contained in:
William Brawner 2021-08-26 07:15:14 -06:00
parent 5d26ee9af3
commit 1ab9af9d17
16 changed files with 947 additions and 4 deletions

View file

@ -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)
}
}
}
}
}

View file

@ -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
)

View file

@ -31,6 +31,9 @@ dependencies {
implementation("io.ktor:ktor-server-sessions:$ktorVersion") implementation("io.ktor:ktor-server-sessions:$ktorVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0")
implementation("ch.qos.logback:logback-classic:1.2.3") 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" description = "twigs-server"
@ -99,3 +102,7 @@ tasks.register("publish") {
} }
} }
} }
tasks.getByName<Test>("test") {
useJUnitPlatform()
}

View file

@ -44,6 +44,7 @@ fun Application.module() {
budgetRepository = JdbcBudgetRepository(it), budgetRepository = JdbcBudgetRepository(it),
categoryRepository = JdbcCategoryRepository(it), categoryRepository = JdbcCategoryRepository(it),
permissionRepository = JdbcPermissionRepository(it), permissionRepository = JdbcPermissionRepository(it),
// recurringTransactionRepository = Fa,
sessionRepository = JdbcSessionRepository(it), sessionRepository = JdbcSessionRepository(it),
transactionRepository = JdbcTransactionRepository(it), transactionRepository = JdbcTransactionRepository(it),
userRepository = JdbcUserRepository(it) userRepository = JdbcUserRepository(it)
@ -57,6 +58,7 @@ fun Application.moduleWithDependencies(
budgetRepository: BudgetRepository, budgetRepository: BudgetRepository,
categoryRepository: CategoryRepository, categoryRepository: CategoryRepository,
permissionRepository: PermissionRepository, permissionRepository: PermissionRepository,
// recurringTransactionRepository: RecurringTransactionRepository,
sessionRepository: SessionRepository, sessionRepository: SessionRepository,
transactionRepository: TransactionRepository, transactionRepository: TransactionRepository,
userRepository: UserRepository userRepository: UserRepository
@ -130,9 +132,17 @@ fun Application.moduleWithDependencies(
) )
).salt ).salt
} }
val jobs = listOf(
SessionCleanupJob(sessionRepository),
// RecurringTransactionProcessingJob(recurringTransactionRepository, transactionRepository)
)
while (currentCoroutineContext().isActive) { while (currentCoroutineContext().isActive) {
delay(Duration.hours(24)) delay(Duration.hours(24))
sessionRepository.deleteExpired() jobs.forEach { it.run() }
} }
} }
}
interface Job {
suspend fun run()
} }

View file

@ -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))
}
}
}

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -1,2 +1,3 @@
rootProject.name = "twigs" rootProject.name = "twigs"
include("core", "api", "app", "storage", "db", "web") include("core", "api", "app", "storage", "db", "web")
include("testhelpers")

View file

@ -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
View file

@ -0,0 +1 @@
build/

View 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")
}

View file

@ -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
}
}

View file

@ -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 }
}

View file

@ -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
}
}

View file

@ -48,6 +48,6 @@ tasks.register("package") {
} }
} }
tasks.getByName("processResources") { //tasks.getByName("processResources") {
dependsOn.add("package") // dependsOn.add("package")
} //}