diff --git a/api/src/main/kotlin/com/wbrawner/twigs/RecurringTransactionRoutes.kt b/api/src/main/kotlin/com/wbrawner/twigs/RecurringTransactionRoutes.kt new file mode 100644 index 0000000..08582a7 --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/twigs/RecurringTransactionRoutes.kt @@ -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()!! + 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()!! + 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()!! + val request = call.receive() + 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()!! + val request = call.receive() + 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()!! + 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) + } + } + } + } +} diff --git a/api/src/main/kotlin/com/wbrawner/twigs/RecurringTransactionsApi.kt b/api/src/main/kotlin/com/wbrawner/twigs/RecurringTransactionsApi.kt new file mode 100644 index 0000000..2f2e8b0 --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/twigs/RecurringTransactionsApi.kt @@ -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 +) \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9cab751..aaf3f90 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") { + useJUnitPlatform() +} diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt index 28b9107..ce82ab6 100644 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt +++ b/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt @@ -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() } \ No newline at end of file diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/RecurringTransactionProcessingJob.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/RecurringTransactionProcessingJob.kt new file mode 100644 index 0000000..50d69d4 --- /dev/null +++ b/app/src/main/kotlin/com/wbrawner/twigs/server/RecurringTransactionProcessingJob.kt @@ -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)) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/SessionCleanupJob.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/SessionCleanupJob.kt new file mode 100644 index 0000000..3c768a6 --- /dev/null +++ b/app/src/main/kotlin/com/wbrawner/twigs/server/SessionCleanupJob.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/wbrawner/twigs/server/RecurringTransactionProcessingJobTest.kt b/app/src/test/kotlin/com/wbrawner/twigs/server/RecurringTransactionProcessingJobTest.kt new file mode 100644 index 0000000..e45ea0b --- /dev/null +++ b/app/src/test/kotlin/com/wbrawner/twigs/server/RecurringTransactionProcessingJobTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/com/wbrawner/twigs/model/RecurringTransaction.kt b/core/src/main/kotlin/com/wbrawner/twigs/model/RecurringTransaction.kt new file mode 100644 index 0000000..d7b00d8 --- /dev/null +++ b/core/src/main/kotlin/com/wbrawner/twigs/model/RecurringTransaction.kt @@ -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, 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 +} diff --git a/settings.gradle.kts b/settings.gradle.kts index e00464a..865fc01 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,2 +1,3 @@ rootProject.name = "twigs" include("core", "api", "app", "storage", "db", "web") +include("testhelpers") diff --git a/storage/src/main/kotlin/com/wbrawner/twigs/storage/RecurringTransactionRepository.kt b/storage/src/main/kotlin/com/wbrawner/twigs/storage/RecurringTransactionRepository.kt new file mode 100644 index 0000000..5126991 --- /dev/null +++ b/storage/src/main/kotlin/com/wbrawner/twigs/storage/RecurringTransactionRepository.kt @@ -0,0 +1,8 @@ +package com.wbrawner.twigs.storage + +import com.wbrawner.twigs.model.RecurringTransaction +import java.time.Instant + +interface RecurringTransactionRepository : Repository { + suspend fun findAll(now: Instant): List +} \ No newline at end of file diff --git a/testhelpers/.gitignore b/testhelpers/.gitignore new file mode 100644 index 0000000..d163863 --- /dev/null +++ b/testhelpers/.gitignore @@ -0,0 +1 @@ +build/ \ No newline at end of file diff --git a/testhelpers/build.gradle.kts b/testhelpers/build.gradle.kts new file mode 100644 index 0000000..9046410 --- /dev/null +++ b/testhelpers/build.gradle.kts @@ -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") +} diff --git a/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeRecurringTransactionsRepository.kt b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeRecurringTransactionsRepository.kt new file mode 100644 index 0000000..ec4d3be --- /dev/null +++ b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeRecurringTransactionsRepository.kt @@ -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(), RecurringTransactionRepository { + override suspend fun findAll(now: Instant): List = entities.filter { + (it.start == now || it.start.isBefore(now)) && it.end?.isAfter(now) ?: true + } +} \ No newline at end of file diff --git a/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeRepository.kt b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeRepository.kt new file mode 100644 index 0000000..f3f47a1 --- /dev/null +++ b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeRepository.kt @@ -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 : Repository { + val entities = mutableListOf() + + override suspend fun findAll(ids: List?): List = 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 } +} \ No newline at end of file diff --git a/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeTransactionRepository.kt b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeTransactionRepository.kt new file mode 100644 index 0000000..eefbc4e --- /dev/null +++ b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeTransactionRepository.kt @@ -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(), TransactionRepository { + override fun findAll( + ids: List?, + budgetIds: List?, + categoryIds: List?, + expense: Boolean?, + from: Instant?, + to: Instant? + ): List = 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 + } +} \ No newline at end of file diff --git a/web/build.gradle.kts b/web/build.gradle.kts index 5c30962..e796613 100644 --- a/web/build.gradle.kts +++ b/web/build.gradle.kts @@ -48,6 +48,6 @@ tasks.register("package") { } } -tasks.getByName("processResources") { - dependsOn.add("package") -} +//tasks.getByName("processResources") { +// dependsOn.add("package") +//}