Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.ledger.infrastructure.persistence

import com.fincore.ledger.domain.enum.AccountStatus
import com.fincore.ledger.domain.enum.AccountType
import com.fincore.ledger.domain.enum.EntryDirection
import com.fincore.ledger.domain.enum.TransactionStatus
import com.fincore.test.containers.PostgresContainerExtension
import io.kotest.matchers.comparables.shouldBeGreaterThan
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import java.math.BigDecimal
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.UUID

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ExtendWith(PostgresContainerExtension::class)
class OverviewQueryIntegrationTest(
@Autowired private val transactionRepository: TransactionRepository,
@Autowired private val accountRepository: AccountRepository,
@Autowired private val entryRepository: EntryRepository,
@Autowired private val entityManager: TestEntityManager,
) {
companion object {
@JvmStatic
@DynamicPropertySource
fun datasourceProperties(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url") { PostgresContainerExtension.jdbcUrl }
registry.add("spring.datasource.username") { PostgresContainerExtension.username }
registry.add("spring.datasource.password") { PostgresContainerExtension.password }
registry.add("spring.jpa.hibernate.ddl-auto") { "none" }
}
}

// An obscure window inside the last entries partition (< 2027-04-01) so this shared-container
// test is immune to rows committed by other integration tests (ordering + bucket counts).
private val baseHour = Instant.parse("2027-03-15T08:00:00Z").truncatedTo(ChronoUnit.HOURS)

private fun saveAccount(): AccountEntity {
val id = UUID.randomUUID()
return accountRepository.saveAndFlush(
AccountEntity(
id = id,
name = "Test Wallet",
type = AccountType.USER_WALLET,
currency = "USD",
status = AccountStatus.ACTIVE,
metadata = "{}",
version = 0,
createdAt = baseHour,
createdBy = "test",
updatedAt = baseHour,
updatedBy = "test",
),
)
}

private fun saveAccountPair(): Pair<AccountEntity, AccountEntity> = saveAccount() to saveAccount()

private fun saveBalancedTx(
debitAccount: AccountEntity,
creditAccount: AccountEntity,
amount: BigDecimal,
reference: String,
description: String?,
postedAt: Instant,
): TransactionEntity {
val txId = UUID.randomUUID()
val tx =
transactionRepository.saveAndFlush(
TransactionEntity(
id = txId,
reference = reference,
description = description,
status = TransactionStatus.POSTED,
reversesId = null,
metadata = "{}",
postedAt = postedAt,
createdAt = postedAt,
createdBy = "test",
),
)
entryRepository.saveAndFlush(
EntryEntity(
key = EntryKey(id = UUID.randomUUID(), createdAt = postedAt),
transactionId = txId,
accountId = debitAccount.id,
amount = amount,
currency = "USD",
direction = EntryDirection.DEBIT,
postedAt = postedAt,
),
)
entryRepository.saveAndFlush(
EntryEntity(
key = EntryKey(id = UUID.randomUUID(), createdAt = postedAt),
transactionId = txId,
accountId = creditAccount.id,
amount = amount.negate(),
currency = "USD",
direction = EntryDirection.CREDIT,
postedAt = postedAt,
),
)
return tx
}

@Test
fun `findRecentActivity returns label as COALESCE description reference`() {
val (accountA, accountB) = saveAccountPair()
val withDescription =
saveBalancedTx(
debitAccount = accountA,
creditAccount = accountB,
amount = BigDecimal("100.00"),
reference = "ref-desc-${ UUID.randomUUID() }",
description = "wallet top-up",
postedAt = baseHour,
)
val withoutDescription =
saveBalancedTx(
debitAccount = accountA,
creditAccount = accountB,
amount = BigDecimal("50.00"),
reference = "ref-only-${ UUID.randomUUID() }",
description = null,
postedAt = baseHour.minusSeconds(60),
)
entityManager.flush()
entityManager.clear()

val rows = transactionRepository.findRecentActivity(20)

val descRow = rows.first { it.id == withDescription.id.toString() }
descRow.label shouldBe "wallet top-up"

val refRow = rows.first { it.id == withoutDescription.id.toString() }
refRow.label shouldBe withoutDescription.reference
}

@Test
fun `findRecentActivity amount equals sum of positive DEBIT entries`() {
val (accountA, accountB) = saveAccountPair()
val posted =
saveBalancedTx(
debitAccount = accountA,
creditAccount = accountB,
amount = BigDecimal("250.500000000000000000"),
reference = "ref-amount-${ UUID.randomUUID() }",
description = null,
postedAt = baseHour,
)
entityManager.flush()
entityManager.clear()

val rows = transactionRepository.findRecentActivity(20)
val row = rows.first { it.id == posted.id.toString() }

(row.amount.compareTo(BigDecimal("250.500000000000000000")) == 0) shouldBe true
row.currency shouldBe "USD"
}

@Test
fun `countByHourSince returns correct bucket counts and sum equals total posted`() {
val (accountA, accountB) = saveAccountPair()
val hour1 = baseHour
val hour2 = baseHour.plus(1L, ChronoUnit.HOURS)

// 2 transactions in hour1, 1 in hour2
repeat(2) {
saveBalancedTx(
debitAccount = accountA,
creditAccount = accountB,
amount = BigDecimal("10.00"),
reference = "ref-h1-${ UUID.randomUUID() }",
description = null,
postedAt = hour1.plusSeconds(it.toLong()),
)
}
saveBalancedTx(
debitAccount = accountA,
creditAccount = accountB,
amount = BigDecimal("20.00"),
reference = "ref-h2-${ UUID.randomUUID() }",
description = null,
postedAt = hour2,
)
entityManager.flush()
entityManager.clear()

val since = hour1.minus(1L, ChronoUnit.SECONDS)
val rows = transactionRepository.countByHourSince(since)

val h1Bucket = rows.find { it.bucket.truncatedTo(ChronoUnit.HOURS) == hour1 }
val h2Bucket = rows.find { it.bucket.truncatedTo(ChronoUnit.HOURS) == hour2 }

h1Bucket.shouldNotBeNull()
h1Bucket.cnt shouldBeGreaterThan 1L

h2Bucket.shouldNotBeNull()
h2Bucket.cnt shouldBe 1L

// Sum across all buckets equals total transactions posted after since
val totalFromBuckets = rows.sumOf { it.cnt }
totalFromBuckets shouldBe 3L
}

@Test
fun `findRecentActivity returns results ordered by postedAt desc`() {
val (accountA, accountB) = saveAccountPair()
val older =
saveBalancedTx(
debitAccount = accountA,
creditAccount = accountB,
amount = BigDecimal("10.00"),
reference = "ref-old-${ UUID.randomUUID() }",
description = null,
postedAt = baseHour,
)
val newer =
saveBalancedTx(
debitAccount = accountA,
creditAccount = accountB,
amount = BigDecimal("20.00"),
reference = "ref-new-${ UUID.randomUUID() }",
description = null,
postedAt = baseHour.plus(1L, ChronoUnit.HOURS),
)
entityManager.flush()
entityManager.clear()

val rows = transactionRepository.findRecentActivity(20)
val ids = rows.map { it.id }

val olderIndex = ids.indexOf(older.id.toString())
val newerIndex = ids.indexOf(newer.id.toString())
(newerIndex < olderIndex) shouldBe true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.ledger.api

import com.fincore.ledger.api.dto.response.OverviewResponse
import com.fincore.ledger.api.mapper.LedgerApiMapper
import com.fincore.ledger.application.OverviewService
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@Tag(name = "Overview", description = "Recent ledger activity and 24h transaction sparkline")
@RestController
@RequestMapping("/v1/overview")
class OverviewController(
private val overviewService: OverviewService,
private val mapper: LedgerApiMapper,
) {
@Operation(summary = "Ledger overview", description = "Returns recent ledger activity and a 24-slot hourly transaction sparkline.")
@ApiResponses(
ApiResponse(responseCode = "200", description = "Overview snapshot"),
ApiResponse(responseCode = "401", description = "Missing or invalid bearer token"),
ApiResponse(responseCode = "403", description = "Insufficient scope"),
)
@GetMapping
fun overview(): OverviewResponse = mapper.toResponse(overviewService.overview())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.ledger.api.dto.response

import com.fasterxml.jackson.databind.annotation.JsonSerialize
import com.fincore.ledger.api.serialization.MoneyAmountSerializer
import io.swagger.v3.oas.annotations.media.Schema
import java.math.BigDecimal
import java.time.Instant

data class OverviewActivityResponse(
val type: String,
val resourceId: String,
val label: String,
@JsonSerialize(using = MoneyAmountSerializer::class)
@Schema(type = "string", format = "decimal", example = "100.00", nullable = true)
val amount: BigDecimal?,
val currency: String?,
val occurredAt: Instant,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.ledger.api.dto.response

data class OverviewResponse(
val activity: List<OverviewActivityResponse>,
val transactionsLast24h: List<Int>,
)
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,35 @@ package com.fincore.ledger.api.mapper

import com.fincore.core.AccountId
import com.fincore.core.Currency
import com.fincore.core.TransactionId
import com.fincore.ledger.api.dto.request.CreateAccountRequest
import com.fincore.ledger.api.dto.request.PostTransactionRequest
import com.fincore.ledger.api.dto.response.AccountEntryResponse
import com.fincore.ledger.api.dto.response.AccountResponse
import com.fincore.ledger.api.dto.response.BalanceResponse
import com.fincore.ledger.api.dto.response.EntryPageResponse
import com.fincore.ledger.api.dto.response.EntryResponse
import com.fincore.ledger.api.dto.response.OverviewActivityResponse
import com.fincore.ledger.api.dto.response.OverviewResponse
import com.fincore.ledger.api.dto.response.PageResponse
import com.fincore.ledger.api.dto.response.TransactionDetailResponse
import com.fincore.ledger.api.dto.response.TransactionResponse
import com.fincore.ledger.application.AccountBalance
import com.fincore.ledger.application.AccountEntry
import com.fincore.ledger.application.AccountEntryPage
import com.fincore.ledger.application.AccountPage
import com.fincore.ledger.application.ActivityItem
import com.fincore.ledger.application.CreateAccountCommand
import com.fincore.ledger.application.EntryLine
import com.fincore.ledger.application.EntryView
import com.fincore.ledger.application.OverviewSnapshot
import com.fincore.ledger.application.PostTransactionCommand
import com.fincore.ledger.application.PostedTransaction
import com.fincore.ledger.application.TransactionDetail
import com.fincore.ledger.application.TransactionPage
import com.fincore.ledger.application.TransactionSummary
import com.fincore.ledger.domain.Account
import com.fincore.ledger.domain.enum.ActivityType
import org.springframework.stereotype.Component

// Hand-written, not MapStruct: the command/domain side uses Kotlin value classes (AccountId, Currency,
Expand Down Expand Up @@ -155,4 +161,27 @@ class LedgerApiMapper {
totalElements = page.totalElements,
totalPages = page.totalPages,
)

fun toResponse(snapshot: OverviewSnapshot): OverviewResponse =
OverviewResponse(
activity = snapshot.activity.map(::toActivityResponse),
transactionsLast24h = snapshot.transactionsLast24h,
)

private fun toActivityResponse(item: ActivityItem): OverviewActivityResponse {
val (typeString, resourceId) =
when (item.type) {
ActivityType.TRANSACTION_POSTED -> "transaction.posted" to TransactionId(item.resourceId).toString()
ActivityType.TRANSACTION_REVERSED -> "transaction.reversed" to TransactionId(item.resourceId).toString()
ActivityType.ACCOUNT_CREATED -> "account.created" to AccountId(item.resourceId).toString()
}
return OverviewActivityResponse(
type = typeString,
resourceId = resourceId,
label = item.label,
amount = item.amount,
currency = item.currency,
occurredAt = item.occurredAt,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.ledger.application

import com.fincore.ledger.domain.enum.ActivityType
import java.math.BigDecimal
import java.time.Instant
import java.util.UUID

data class ActivityItem(
val type: ActivityType,
val resourceId: UUID,
val label: String,
val amount: BigDecimal?,
val currency: String?,
val occurredAt: Instant,
)
Loading
Loading