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("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()
|
||||||
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
|
@ -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"
|
rootProject.name = "twigs"
|
||||||
include("core", "api", "app", "storage", "db", "web")
|
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") {
|
//tasks.getByName("processResources") {
|
||||||
dependsOn.add("package")
|
// dependsOn.add("package")
|
||||||
}
|
//}
|
||||||
|
|
Loading…
Reference in a new issue