From 11649930db391e200e96200211753602577e9393 Mon Sep 17 00:00:00 2001 From: "@tanya_r" Date: Mon, 29 Jun 2026 00:44:15 -0300 Subject: [PATCH 1/5] feat(overview): drive the sandbox overview from live ledger data Add a ledger read endpoint GET /v1/overview returning recent ledger activity and the real 24h hourly transaction count, seed demo transactions through a Liquibase demo changeset, and wire the web overview to live data. Drop the fabricated mock, the version/build/uptime line and the fake service-health chips; offline and empty states show placeholders instead of invented numbers. --- .../fincore/ledger/api/OverviewDemoSeedIT.kt | 80 ++++++ .../OverviewQueryIntegrationTest.kt | 245 ++++++++++++++++++ .../fincore/ledger/api/OverviewController.kt | 32 +++ .../dto/response/OverviewActivityResponse.kt | 21 ++ .../api/dto/response/OverviewResponse.kt | 9 + .../ledger/api/mapper/LedgerApiMapper.kt | 29 +++ .../ledger/application/ActivityItem.kt | 18 ++ .../ledger/application/OverviewService.kt | 8 + .../ledger/application/OverviewServiceImpl.kt | 82 ++++++ .../ledger/application/OverviewSnapshot.kt | 9 + .../ledger/domain/enum/ActivityType.kt | 10 + .../persistence/AccountRepository.kt | 5 +- .../persistence/HourlyTransactionCountRow.kt | 15 ++ .../persistence/TransactionActivityRow.kt | 20 ++ .../persistence/TransactionRepository.kt | 42 +++ .../db/changelog/db.changelog-master.yaml | 3 + .../changelog/demo/002-demo-transactions.sql | 66 +++++ .../ledger/api/OverviewControllerTest.kt | 174 +++++++++++++ .../application/OverviewServiceImplTest.kt | 180 +++++++++++++ web/src/api/types.ts | 16 ++ web/src/features/overview/ActivityFeed.tsx | 123 +++++---- web/src/features/overview/OverviewTopBar.tsx | 93 ++++--- web/src/features/overview/activityView.ts | 31 +++ web/src/features/overview/liveKpis.ts | 32 ++- web/src/features/overview/overviewConfig.ts | 53 ++++ .../features/overview/useLedgerOverview.ts | 17 ++ web/src/lib/jwt.ts | 37 +++ web/src/lib/relativeTime.ts | 14 + web/src/lib/series.ts | 13 - web/src/mock/overview.ts | 181 ------------- web/src/mock/types.ts | 24 -- web/src/routes/Overview.tsx | 53 ++-- web/src/test/Overview.test.tsx | 137 ++++------ web/src/test/sparkline.test.ts | 21 +- 34 files changed, 1453 insertions(+), 440 deletions(-) create mode 100644 services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/OverviewDemoSeedIT.kt create mode 100644 services/ledger/src/integrationTest/kotlin/com/fincore/ledger/infrastructure/persistence/OverviewQueryIntegrationTest.kt create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/api/OverviewController.kt create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/response/OverviewActivityResponse.kt create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/response/OverviewResponse.kt create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/application/ActivityItem.kt create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/application/OverviewService.kt create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/application/OverviewServiceImpl.kt create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/application/OverviewSnapshot.kt create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/domain/enum/ActivityType.kt create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/HourlyTransactionCountRow.kt create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/TransactionActivityRow.kt create mode 100644 services/ledger/src/main/resources/db/changelog/demo/002-demo-transactions.sql create mode 100644 services/ledger/src/test/kotlin/com/fincore/ledger/api/OverviewControllerTest.kt create mode 100644 services/ledger/src/test/kotlin/com/fincore/ledger/application/OverviewServiceImplTest.kt create mode 100644 web/src/features/overview/activityView.ts create mode 100644 web/src/features/overview/overviewConfig.ts create mode 100644 web/src/features/overview/useLedgerOverview.ts create mode 100644 web/src/lib/jwt.ts create mode 100644 web/src/lib/relativeTime.ts delete mode 100644 web/src/lib/series.ts delete mode 100644 web/src/mock/overview.ts diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/OverviewDemoSeedIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/OverviewDemoSeedIT.kt new file mode 100644 index 0000000..f5336cc --- /dev/null +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/OverviewDemoSeedIT.kt @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.api + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fincore.test.containers.PostgresContainerExtension +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.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import java.time.Instant + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ExtendWith(PostgresContainerExtension::class) +@Import(OverviewDemoSeedIT.TestSecurity::class) +class OverviewDemoSeedIT( + @Autowired private val rest: TestRestTemplate, + @Autowired private val objectMapper: ObjectMapper, +) { + @TestConfiguration + class TestSecurity { + @Bean + fun jwtDecoder(): JwtDecoder = + JwtDecoder { token -> + Jwt + .withTokenValue(token) + .header("alg", "none") + .subject("overview-seed-it") + .claim("scope", "ledger:read") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(EXPIRY_SECONDS)) + .build() + } + } + + @Test + fun `demo seed populates the overview feed and sparkline`() { + val headers = HttpHeaders().apply { setBearerAuth("overview-token") } + val response = rest.exchange("/v1/overview", HttpMethod.GET, HttpEntity(headers), String::class.java) + + response.statusCode.value() shouldBe 200 + val tree = objectMapper.readTree(response.body) + + val activity = tree.get("activity") + (activity.size() > 0) shouldBe true + activity.any { it.get("type").asText() == "transaction.posted" } shouldBe true + + val sparkline = tree.get("transactionsLast24h") + sparkline.size() shouldBe SPARK_HOURS + (sparkline.sumOf { it.asInt() } > 0) shouldBe true + } + + companion object { + private const val EXPIRY_SECONDS = 300L + private const val SPARK_HOURS = 24 + + @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" } + registry.add("spring.liquibase.contexts") { "production,demo" } + } + } +} diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/infrastructure/persistence/OverviewQueryIntegrationTest.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/infrastructure/persistence/OverviewQueryIntegrationTest.kt new file mode 100644 index 0000000..2199503 --- /dev/null +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/infrastructure/persistence/OverviewQueryIntegrationTest.kt @@ -0,0 +1,245 @@ +// 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.collections.shouldHaveSize +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" } + } + } + + private val baseHour = Instant.parse("2026-06-28T08: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 = 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 + } +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/api/OverviewController.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/api/OverviewController.kt new file mode 100644 index 0000000..7a914b0 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/api/OverviewController.kt @@ -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()) +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/response/OverviewActivityResponse.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/response/OverviewActivityResponse.kt new file mode 100644 index 0000000..3c159de --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/response/OverviewActivityResponse.kt @@ -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, +) diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/response/OverviewResponse.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/response/OverviewResponse.kt new file mode 100644 index 0000000..1476ee9 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/response/OverviewResponse.kt @@ -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, + val transactionsLast24h: List, +) diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/api/mapper/LedgerApiMapper.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/api/mapper/LedgerApiMapper.kt index 75ced24..59ff332 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/api/mapper/LedgerApiMapper.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/api/mapper/LedgerApiMapper.kt @@ -5,6 +5,7 @@ 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 @@ -12,6 +13,8 @@ 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 @@ -19,15 +22,18 @@ 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, @@ -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, + ) + } } diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/ActivityItem.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/ActivityItem.kt new file mode 100644 index 0000000..b9f7afb --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/ActivityItem.kt @@ -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, +) diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/OverviewService.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/OverviewService.kt new file mode 100644 index 0000000..59f9efc --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/OverviewService.kt @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.application + +interface OverviewService { + fun overview(): OverviewSnapshot +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/OverviewServiceImpl.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/OverviewServiceImpl.kt new file mode 100644 index 0000000..9fdf0d3 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/OverviewServiceImpl.kt @@ -0,0 +1,82 @@ +// 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 com.fincore.ledger.infrastructure.persistence.AccountRepository +import com.fincore.ledger.infrastructure.persistence.TransactionRepository +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.UUID + +@Service +class OverviewServiceImpl( + private val transactionRepository: TransactionRepository, + private val accountRepository: AccountRepository, +) : OverviewService { + @Transactional(readOnly = true) + override fun overview(): OverviewSnapshot { + val txItems = + transactionRepository.findRecentActivity(ACTIVITY_LIMIT).map { row -> + ActivityItem( + type = if (row.reversal) ActivityType.TRANSACTION_REVERSED else ActivityType.TRANSACTION_POSTED, + resourceId = UUID.fromString(row.id), + label = row.label, + amount = row.amount, + currency = row.currency, + occurredAt = row.postedat, + ) + } + + val accountItems = + accountRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(0, ACTIVITY_LIMIT)).map { entity -> + ActivityItem( + type = ActivityType.ACCOUNT_CREATED, + resourceId = entity.id, + label = entity.name, + amount = null, + currency = entity.currency, + occurredAt = entity.createdAt, + ) + } + + val activity = + (txItems + accountItems) + .sortedByDescending { it.occurredAt } + .take(ACTIVITY_LIMIT) + + val sparkline = buildSparkline() + + return OverviewSnapshot(activity = activity, transactionsLast24h = sparkline) + } + + /** + * Builds a dense 24-slot hourly array (oldest-first) by bucketing raw posted_at + * Instants in the JVM, avoiding any SQL session-timezone dependency (critic finding 5). + * Slot 0 = the hour starting 23h before the current truncated hour; slot 23 = current hour. + */ + private fun buildSparkline(): List { + val nowHour = Instant.now().truncatedTo(ChronoUnit.HOURS) + val since = nowHour.minus((SPARK_HOURS - 1).toLong(), ChronoUnit.HOURS) + val rows = transactionRepository.countByHourSince(since) + + val counts = IntArray(SPARK_HOURS) + for (row in rows) { + val bucketHour = row.bucket.truncatedTo(ChronoUnit.HOURS) + val slotIndex = ChronoUnit.HOURS.between(since, bucketHour).toInt() + if (slotIndex in 0 until SPARK_HOURS) { + counts[slotIndex] = row.cnt.toInt() + } + } + return counts.toList() + } + + private companion object { + const val ACTIVITY_LIMIT = 20 + const val SPARK_HOURS = 24 + } +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/OverviewSnapshot.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/OverviewSnapshot.kt new file mode 100644 index 0000000..a052fbf --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/OverviewSnapshot.kt @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.application + +data class OverviewSnapshot( + val activity: List, + val transactionsLast24h: List, +) diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/domain/enum/ActivityType.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/domain/enum/ActivityType.kt new file mode 100644 index 0000000..085af51 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/domain/enum/ActivityType.kt @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.domain.enum + +enum class ActivityType { + TRANSACTION_POSTED, + TRANSACTION_REVERSED, + ACCOUNT_CREATED, +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/AccountRepository.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/AccountRepository.kt index 53de11b..37bc3b1 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/AccountRepository.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/AccountRepository.kt @@ -3,7 +3,10 @@ package com.fincore.ledger.infrastructure.persistence +import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository import java.util.UUID -interface AccountRepository : JpaRepository +interface AccountRepository : JpaRepository { + fun findAllByOrderByCreatedAtDesc(pageable: Pageable): List +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/HourlyTransactionCountRow.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/HourlyTransactionCountRow.kt new file mode 100644 index 0000000..afa0910 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/HourlyTransactionCountRow.kt @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.infrastructure.persistence + +import java.time.Instant + +/** + * Spring Data projection for the hourly sparkline native query. + * Field names match the lowercased SQL aliases emitted by PostgreSQL. + */ +interface HourlyTransactionCountRow { + val bucket: Instant + val cnt: Long +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/TransactionActivityRow.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/TransactionActivityRow.kt new file mode 100644 index 0000000..d4b8901 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/TransactionActivityRow.kt @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.infrastructure.persistence + +import java.math.BigDecimal +import java.time.Instant + +/** + * Spring Data projection for the recent-activity native query. + * Field names match the lowercased SQL aliases emitted by PostgreSQL. + */ +interface TransactionActivityRow { + val id: String + val label: String + val reversal: Boolean + val postedat: Instant + val amount: BigDecimal + val currency: String? +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/TransactionRepository.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/TransactionRepository.kt index 903e9c1..a92a851 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/TransactionRepository.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/TransactionRepository.kt @@ -4,8 +4,50 @@ package com.fincore.ledger.infrastructure.persistence import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.Instant import java.util.UUID interface TransactionRepository : JpaRepository { fun existsByReference(reference: String): Boolean + + @Query( + nativeQuery = true, + value = """ + SELECT CAST(t.id AS varchar) AS id, + COALESCE(t.description, t.reference) AS label, + (t.reverses_id IS NOT NULL) AS reversal, + t.posted_at AS postedat, + COALESCE((SELECT SUM(e.amount) + FROM ledger.entries e + WHERE e.transaction_id = t.id AND e.amount > 0), 0) AS amount, + (SELECT MIN(e.currency) + FROM ledger.entries e + WHERE e.transaction_id = t.id) AS currency + FROM ledger.transactions t + ORDER BY t.posted_at DESC, t.id DESC + LIMIT :limit + """, + ) + fun findRecentActivity( + @Param("limit") limit: Int, + ): List + + @Query( + nativeQuery = true, + // Truncate to the UTC hour and cast back to timestamptz so the bucket maps to a + // UTC-anchored Instant regardless of the JDBC session/JVM timezone. + value = """ + SELECT date_trunc('hour', posted_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS bucket, + COUNT(*) AS cnt + FROM ledger.transactions + WHERE posted_at >= :since + GROUP BY bucket + ORDER BY bucket + """, + ) + fun countByHourSince( + @Param("since") since: Instant, + ): List } diff --git a/services/ledger/src/main/resources/db/changelog/db.changelog-master.yaml b/services/ledger/src/main/resources/db/changelog/db.changelog-master.yaml index 92e51cc..65d8014 100644 --- a/services/ledger/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/services/ledger/src/main/resources/db/changelog/db.changelog-master.yaml @@ -43,3 +43,6 @@ databaseChangeLog: - include: file: demo/001-demo-accounts.sql relativeToChangelogFile: true + - include: + file: demo/002-demo-transactions.sql + relativeToChangelogFile: true diff --git a/services/ledger/src/main/resources/db/changelog/demo/002-demo-transactions.sql b/services/ledger/src/main/resources/db/changelog/demo/002-demo-transactions.sql new file mode 100644 index 0000000..15bbd2c --- /dev/null +++ b/services/ledger/src/main/resources/db/changelog/demo/002-demo-transactions.sql @@ -0,0 +1,66 @@ +--liquibase formatted sql +-- SPDX-License-Identifier: BUSL-1.1 +-- SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +-- Both entries of a transaction are inserted in this single changeset so the deferred +-- double-entry trigger (validated at changeset commit) sees a per-(transaction,currency) +-- sum of zero. Timestamps are NOW()-relative so a fresh sandbox shows a populated 24h +-- sparkline immediately; idempotency is provided by Liquibase changeset tracking. +--changeset fincore:demo-002-transactions context:demo dbms:postgresql +INSERT INTO ledger.transactions (id, reference, description, status, reverses_id, posted_at, created_at, created_by) VALUES + ('00000000-0000-0000-0001-000000000001', 'demo-tx-001', 'wallet top-up', 'POSTED', NULL, NOW() - INTERVAL '26 hours', NOW() - INTERVAL '26 hours', 'demo-seed'), + ('00000000-0000-0000-0001-000000000002', 'demo-tx-002', 'card settlement', 'POSTED', NULL, NOW() - INTERVAL '22 hours', NOW() - INTERVAL '22 hours', 'demo-seed'), + ('00000000-0000-0000-0001-000000000003', 'demo-tx-003', 'service fee', 'POSTED', NULL, NOW() - INTERVAL '20 hours', NOW() - INTERVAL '20 hours', 'demo-seed'), + ('00000000-0000-0000-0001-000000000004', 'demo-tx-004', 'wallet top-up', 'POSTED', NULL, NOW() - INTERVAL '14 hours', NOW() - INTERVAL '14 hours', 'demo-seed'), + ('00000000-0000-0000-0001-000000000005', 'demo-tx-005', 'hold to suspense', 'POSTED', NULL, NOW() - INTERVAL '10 hours', NOW() - INTERVAL '10 hours', 'demo-seed'), + ('00000000-0000-0000-0001-000000000006', 'demo-tx-006', 'service fee', 'POSTED', NULL, NOW() - INTERVAL '8 hours', NOW() - INTERVAL '8 hours', 'demo-seed'), + ('00000000-0000-0000-0001-000000000007', 'demo-tx-007', 'release from suspense', 'POSTED', NULL, NOW() - INTERVAL '6 hours', NOW() - INTERVAL '6 hours', 'demo-seed'), + ('00000000-0000-0000-0001-000000000008', 'demo-tx-008', 'settlement payout', 'POSTED', NULL, NOW() - INTERVAL '4 hours', NOW() - INTERVAL '4 hours', 'demo-seed'), + ('00000000-0000-0000-0001-000000000009', 'demo-tx-009', 'service fee', 'POSTED', NULL, NOW() - INTERVAL '2 hours', NOW() - INTERVAL '2 hours', 'demo-seed'), + ('00000000-0000-0000-0001-000000000010', 'demo-tx-010', 'wallet top-up', 'POSTED', NULL, NOW() - INTERVAL '30 minutes', NOW() - INTERVAL '30 minutes', 'demo-seed') +ON CONFLICT (id) DO NOTHING; + +-- Each pair: DEBIT account gets +amount, CREDIT account gets -amount (single currency, sum = 0). +-- Accounts: W=...0001 wallet USD, F=...0003 fee, R=...0004 reserve, S=...0005 suspense. +INSERT INTO ledger.entries (id, transaction_id, account_id, amount, currency, direction, posted_at, created_at) VALUES + ('00000000-0000-0000-0002-000000000001', '00000000-0000-0000-0001-000000000001', '00000000-0000-0000-0000-000000000001', 500.00, 'USD', 'DEBIT', NOW() - INTERVAL '26 hours', NOW() - INTERVAL '26 hours'), + ('00000000-0000-0000-0002-000000000002', '00000000-0000-0000-0001-000000000001', '00000000-0000-0000-0000-000000000004', -500.00, 'USD', 'CREDIT', NOW() - INTERVAL '26 hours', NOW() - INTERVAL '26 hours'), + ('00000000-0000-0000-0002-000000000003', '00000000-0000-0000-0001-000000000002', '00000000-0000-0000-0000-000000000004', 120.50, 'USD', 'DEBIT', NOW() - INTERVAL '22 hours', NOW() - INTERVAL '22 hours'), + ('00000000-0000-0000-0002-000000000004', '00000000-0000-0000-0001-000000000002', '00000000-0000-0000-0000-000000000001', -120.50, 'USD', 'CREDIT', NOW() - INTERVAL '22 hours', NOW() - INTERVAL '22 hours'), + ('00000000-0000-0000-0002-000000000005', '00000000-0000-0000-0001-000000000003', '00000000-0000-0000-0000-000000000001', 2.50, 'USD', 'DEBIT', NOW() - INTERVAL '20 hours', NOW() - INTERVAL '20 hours'), + ('00000000-0000-0000-0002-000000000006', '00000000-0000-0000-0001-000000000003', '00000000-0000-0000-0000-000000000003', -2.50, 'USD', 'CREDIT', NOW() - INTERVAL '20 hours', NOW() - INTERVAL '20 hours'), + ('00000000-0000-0000-0002-000000000007', '00000000-0000-0000-0001-000000000004', '00000000-0000-0000-0000-000000000001', 1000.00, 'USD', 'DEBIT', NOW() - INTERVAL '14 hours', NOW() - INTERVAL '14 hours'), + ('00000000-0000-0000-0002-000000000008', '00000000-0000-0000-0001-000000000004', '00000000-0000-0000-0000-000000000004', -1000.00, 'USD', 'CREDIT', NOW() - INTERVAL '14 hours', NOW() - INTERVAL '14 hours'), + ('00000000-0000-0000-0002-000000000009', '00000000-0000-0000-0001-000000000005', '00000000-0000-0000-0000-000000000005', 75.00, 'USD', 'DEBIT', NOW() - INTERVAL '10 hours', NOW() - INTERVAL '10 hours'), + ('00000000-0000-0000-0002-000000000010', '00000000-0000-0000-0001-000000000005', '00000000-0000-0000-0000-000000000001', -75.00, 'USD', 'CREDIT', NOW() - INTERVAL '10 hours', NOW() - INTERVAL '10 hours'), + ('00000000-0000-0000-0002-000000000011', '00000000-0000-0000-0001-000000000006', '00000000-0000-0000-0000-000000000001', 1.25, 'USD', 'DEBIT', NOW() - INTERVAL '8 hours', NOW() - INTERVAL '8 hours'), + ('00000000-0000-0000-0002-000000000012', '00000000-0000-0000-0001-000000000006', '00000000-0000-0000-0000-000000000003', -1.25, 'USD', 'CREDIT', NOW() - INTERVAL '8 hours', NOW() - INTERVAL '8 hours'), + ('00000000-0000-0000-0002-000000000013', '00000000-0000-0000-0001-000000000007', '00000000-0000-0000-0000-000000000001', 75.00, 'USD', 'DEBIT', NOW() - INTERVAL '6 hours', NOW() - INTERVAL '6 hours'), + ('00000000-0000-0000-0002-000000000014', '00000000-0000-0000-0001-000000000007', '00000000-0000-0000-0000-000000000005', -75.00, 'USD', 'CREDIT', NOW() - INTERVAL '6 hours', NOW() - INTERVAL '6 hours'), + ('00000000-0000-0000-0002-000000000015', '00000000-0000-0000-0001-000000000008', '00000000-0000-0000-0000-000000000004', 300.00, 'USD', 'DEBIT', NOW() - INTERVAL '4 hours', NOW() - INTERVAL '4 hours'), + ('00000000-0000-0000-0002-000000000016', '00000000-0000-0000-0001-000000000008', '00000000-0000-0000-0000-000000000001', -300.00, 'USD', 'CREDIT', NOW() - INTERVAL '4 hours', NOW() - INTERVAL '4 hours'), + ('00000000-0000-0000-0002-000000000017', '00000000-0000-0000-0001-000000000009', '00000000-0000-0000-0000-000000000001', 3.10, 'USD', 'DEBIT', NOW() - INTERVAL '2 hours', NOW() - INTERVAL '2 hours'), + ('00000000-0000-0000-0002-000000000018', '00000000-0000-0000-0001-000000000009', '00000000-0000-0000-0000-000000000003', -3.10, 'USD', 'CREDIT', NOW() - INTERVAL '2 hours', NOW() - INTERVAL '2 hours'), + ('00000000-0000-0000-0002-000000000019', '00000000-0000-0000-0001-000000000010', '00000000-0000-0000-0000-000000000001', 250.00, 'USD', 'DEBIT', NOW() - INTERVAL '30 minutes', NOW() - INTERVAL '30 minutes'), + ('00000000-0000-0000-0002-000000000020', '00000000-0000-0000-0001-000000000010', '00000000-0000-0000-0000-000000000004', -250.00, 'USD', 'CREDIT', NOW() - INTERVAL '30 minutes', NOW() - INTERVAL '30 minutes'); + +-- Derive balances from the seeded entries so totals stay consistent with no hand-computed +-- magic numbers; ON CONFLICT keeps it safe if a balance row already exists. +--changeset fincore:demo-002-balances context:demo dbms:postgresql +INSERT INTO ledger.account_balances (account_id, currency, balance, last_posted_at, version) +SELECT e.account_id, e.currency, SUM(e.amount), MAX(e.posted_at), 0 +FROM ledger.entries e +WHERE e.transaction_id IN ( + '00000000-0000-0000-0001-000000000001', + '00000000-0000-0000-0001-000000000002', + '00000000-0000-0000-0001-000000000003', + '00000000-0000-0000-0001-000000000004', + '00000000-0000-0000-0001-000000000005', + '00000000-0000-0000-0001-000000000006', + '00000000-0000-0000-0001-000000000007', + '00000000-0000-0000-0001-000000000008', + '00000000-0000-0000-0001-000000000009', + '00000000-0000-0000-0001-000000000010' +) +GROUP BY e.account_id, e.currency +ON CONFLICT (account_id, currency) DO NOTHING; diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/api/OverviewControllerTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/api/OverviewControllerTest.kt new file mode 100644 index 0000000..95d9995 --- /dev/null +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/api/OverviewControllerTest.kt @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.api + +import com.fincore.ledger.api.error.AuditEndpointResolver +import com.fincore.ledger.api.idempotency.IdempotencyFilter +import com.fincore.ledger.api.mapper.LedgerApiMapper +import com.fincore.ledger.application.ActivityItem +import com.fincore.ledger.application.AuditTrailWriter +import com.fincore.ledger.application.OverviewService +import com.fincore.ledger.application.OverviewSnapshot +import com.fincore.ledger.config.AuditingAccessDeniedHandler +import com.fincore.ledger.config.SecurityConfig +import com.fincore.ledger.domain.enum.ActivityType +import com.fincore.ledger.exception.FailureAuditRecorder +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.math.BigDecimal +import java.time.Instant +import java.util.UUID + +@WebMvcTest(OverviewController::class) +@Import( + SecurityConfig::class, + LedgerApiMapper::class, + IdempotencyFilter::class, + AuditEndpointResolver::class, + FailureAuditRecorder::class, + AuditingAccessDeniedHandler::class, + OverviewControllerTest.Mocks::class, +) +class OverviewControllerTest( + @Autowired private val mockMvc: MockMvc, + @Autowired private val overviewService: OverviewService, +) { + @TestConfiguration + class Mocks { + @Bean fun overviewService(): OverviewService = mockk() + + @Bean fun auditTrailWriter(): AuditTrailWriter = mockk(relaxed = true) + + @Bean fun jwtDecoder(): JwtDecoder = mockk() + } + + private val postedAt = Instant.parse("2026-06-28T10:01:02Z") + + private fun emptySnapshot() = + OverviewSnapshot( + activity = emptyList(), + transactionsLast24h = List(24) { 0 }, + ) + + private fun snapshotWithActivity(): OverviewSnapshot { + val txItem = + ActivityItem( + type = ActivityType.TRANSACTION_POSTED, + resourceId = UUID.fromString("018f7e2a-0000-7000-8000-000000000001"), + label = "wallet top-up", + amount = BigDecimal("500.000000000000000000"), + currency = "USD", + occurredAt = postedAt, + ) + val sparkline = List(24) { if (it == 23) 1 else 0 } + return OverviewSnapshot(activity = listOf(txItem), transactionsLast24h = sparkline) + } + + @Test + fun `should return 401 when unauthenticated`() { + mockMvc.perform(get("/v1/overview")).andExpect(status().isUnauthorized) + } + + @Test + fun `should return 403 when authenticated with write scope only`() { + every { overviewService.overview() } returns emptySnapshot() + + mockMvc + .perform(get("/v1/overview").with(jwt().authorities(SimpleGrantedAuthority(SCOPE_WRITE)))) + .andExpect(status().isForbidden) + } + + @Test + fun `should return 200 with read scope`() { + every { overviewService.overview() } returns emptySnapshot() + + mockMvc + .perform(get("/v1/overview").with(jwt().authorities(SimpleGrantedAuthority(SCOPE_READ)))) + .andExpect(status().isOk) + } + + @Test + fun `should return transactionsLast24h as an array of exactly 24 integers`() { + every { overviewService.overview() } returns emptySnapshot() + + mockMvc + .perform(get("/v1/overview").with(readJwt())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.transactionsLast24h").isArray) + .andExpect(jsonPath("$.transactionsLast24h.length()").value(24)) + } + + @Test + fun `should return empty activity array on empty ledger`() { + every { overviewService.overview() } returns emptySnapshot() + + mockMvc + .perform(get("/v1/overview").with(readJwt())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.activity").isArray) + .andExpect(jsonPath("$.activity.length()").value(0)) + } + + @Test + fun `should serialize amount as a string not a number`() { + every { overviewService.overview() } returns snapshotWithActivity() + + mockMvc + .perform(get("/v1/overview").with(readJwt())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.activity[0].amount").isString) + } + + @Test + fun `should emit dotted type string for transaction posted`() { + every { overviewService.overview() } returns snapshotWithActivity() + + mockMvc + .perform(get("/v1/overview").with(readJwt())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.activity[0].type").value("transaction.posted")) + } + + @Test + fun `should prefix transaction resourceId with tx_`() { + every { overviewService.overview() } returns snapshotWithActivity() + + mockMvc + .perform(get("/v1/overview").with(readJwt())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.activity[0].resourceId").value(org.hamcrest.Matchers.startsWith("tx_"))) + } + + @Test + fun `should include label and currency on activity items`() { + every { overviewService.overview() } returns snapshotWithActivity() + + mockMvc + .perform(get("/v1/overview").with(readJwt())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.activity[0].label").value("wallet top-up")) + .andExpect(jsonPath("$.activity[0].currency").value("USD")) + .andExpect(jsonPath("$.activity[0].occurredAt").value(postedAt.toString())) + } + + private fun readJwt() = jwt().authorities(SimpleGrantedAuthority(SCOPE_READ)) + + private companion object { + const val SCOPE_READ = "SCOPE_ledger:read" + const val SCOPE_WRITE = "SCOPE_ledger:write" + } +} diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/application/OverviewServiceImplTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/application/OverviewServiceImplTest.kt new file mode 100644 index 0000000..26e9c97 --- /dev/null +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/application/OverviewServiceImplTest.kt @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.application + +import com.fincore.ledger.domain.enum.AccountStatus +import com.fincore.ledger.domain.enum.AccountType +import com.fincore.ledger.domain.enum.ActivityType +import com.fincore.ledger.infrastructure.persistence.AccountEntity +import com.fincore.ledger.infrastructure.persistence.AccountRepository +import com.fincore.ledger.infrastructure.persistence.HourlyTransactionCountRow +import com.fincore.ledger.infrastructure.persistence.TransactionActivityRow +import com.fincore.ledger.infrastructure.persistence.TransactionRepository +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import org.springframework.data.domain.Pageable +import java.math.BigDecimal +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.UUID + +class OverviewServiceImplTest { + private val transactionRepository = mockk() + private val accountRepository = mockk() + private val service = OverviewServiceImpl(transactionRepository, accountRepository) + + private val base = Instant.parse("2026-06-28T10:00:00Z") + + private fun txRow( + id: UUID = UUID.randomUUID(), + label: String = "wallet top-up", + reversal: Boolean = false, + postedAt: Instant = base, + amount: BigDecimal = BigDecimal("100.00"), + currency: String = "USD", + ): TransactionActivityRow = + object : TransactionActivityRow { + override val id = id.toString() + override val label = label + override val reversal = reversal + override val postedat = postedAt + override val amount = amount + override val currency = currency + } + + private fun accountEntity( + id: UUID = UUID.randomUUID(), + name: String = "Customer Wallet", + createdAt: Instant = base.minusSeconds(3600), + ) = AccountEntity( + id = id, + name = name, + type = AccountType.USER_WALLET, + currency = "USD", + status = AccountStatus.ACTIVE, + metadata = "{}", + version = 0, + createdAt = createdAt, + createdBy = "system", + updatedAt = createdAt, + updatedBy = "system", + ) + + private fun hourRow( + bucket: Instant, + cnt: Long, + ): HourlyTransactionCountRow = + object : HourlyTransactionCountRow { + override val bucket = bucket + override val cnt = cnt + } + + @Test + fun `should map reverses_id presence to TRANSACTION_REVERSED type`() { + every { transactionRepository.findRecentActivity(any()) } returns listOf( + txRow(reversal = false), + txRow(reversal = true), + ) + every { accountRepository.findAllByOrderByCreatedAtDesc(any()) } returns emptyList() + every { transactionRepository.countByHourSince(any()) } returns emptyList() + + val snapshot = service.overview() + + snapshot.activity[0].type shouldBe ActivityType.TRANSACTION_POSTED + snapshot.activity[1].type shouldBe ActivityType.TRANSACTION_REVERSED + } + + @Test + fun `should merge transactions and accounts sorted by occurredAt descending`() { + val txTime = base + val accountTime = base.plusSeconds(60) + + every { transactionRepository.findRecentActivity(any()) } returns listOf(txRow(postedAt = txTime)) + every { accountRepository.findAllByOrderByCreatedAtDesc(any()) } returns listOf( + accountEntity(createdAt = accountTime), + ) + every { transactionRepository.countByHourSince(any()) } returns emptyList() + + val snapshot = service.overview() + + snapshot.activity shouldHaveSize 2 + snapshot.activity[0].occurredAt shouldBe accountTime + snapshot.activity[1].occurredAt shouldBe txTime + snapshot.activity[0].type shouldBe ActivityType.ACCOUNT_CREATED + snapshot.activity[1].type shouldBe ActivityType.TRANSACTION_POSTED + } + + @Test + fun `should limit total activity to 20 items`() { + val txRows = (1..20).map { txRow(postedAt = base.minusSeconds(it.toLong())) } + val accountRows = (1..20).map { accountEntity(createdAt = base.plusSeconds(it.toLong())) } + + every { transactionRepository.findRecentActivity(any()) } returns txRows + every { accountRepository.findAllByOrderByCreatedAtDesc(any()) } returns accountRows + every { transactionRepository.countByHourSince(any()) } returns emptyList() + + val snapshot = service.overview() + + snapshot.activity shouldHaveSize 20 + } + + @Test + fun `should return 24 zeros for sparkline when no transactions exist`() { + every { transactionRepository.findRecentActivity(any()) } returns emptyList() + every { accountRepository.findAllByOrderByCreatedAtDesc(any()) } returns emptyList() + every { transactionRepository.countByHourSince(any()) } returns emptyList() + + val snapshot = service.overview() + + snapshot.transactionsLast24h shouldHaveSize 24 + snapshot.transactionsLast24h.all { it == 0 } shouldBe true + } + + @Test + fun `should densify sparkline with zero-fill and place counts in correct UTC slots`() { + val nowHour = Instant.now().truncatedTo(ChronoUnit.HOURS) + val since = nowHour.minus(23L, ChronoUnit.HOURS) + + // Two transactions in slot 0 (oldest), one transaction in slot 23 (current hour) + val rows = listOf( + hourRow(bucket = since, cnt = 2L), + hourRow(bucket = nowHour, cnt = 1L), + ) + + every { transactionRepository.findRecentActivity(any()) } returns emptyList() + every { accountRepository.findAllByOrderByCreatedAtDesc(any()) } returns emptyList() + every { transactionRepository.countByHourSince(any()) } returns rows + + val snapshot = service.overview() + + val sparkline = snapshot.transactionsLast24h + sparkline shouldHaveSize 24 + sparkline[0] shouldBe 2 // oldest slot + sparkline[23] shouldBe 1 // current hour + sparkline[1] shouldBe 0 // gap filled with zero + sparkline[22] shouldBe 0 + sparkline.sum() shouldBe 3 + } + + @Test + fun `should map account created items with null amount`() { + every { transactionRepository.findRecentActivity(any()) } returns emptyList() + every { accountRepository.findAllByOrderByCreatedAtDesc(any()) } returns listOf( + accountEntity(name = "Settlement Reserve"), + ) + every { transactionRepository.countByHourSince(any()) } returns emptyList() + + val snapshot = service.overview() + + val item = snapshot.activity.single() + item.type shouldBe ActivityType.ACCOUNT_CREATED + item.label shouldBe "Settlement Reserve" + item.amount shouldBe null + item.resourceId shouldNotBe null + } +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts index c807ff0..500d093 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -111,3 +111,19 @@ export interface PaymentResponse { currency: string status: PaymentStatus } + +export type LedgerActivityType = 'transaction.posted' | 'transaction.reversed' | 'account.created' + +export interface LedgerActivityEvent { + type: LedgerActivityType + resourceId: string + label: string + amount: string | null + currency: string | null + occurredAt: string +} + +export interface LedgerOverviewResponse { + activity: LedgerActivityEvent[] + transactionsLast24h: number[] +} diff --git a/web/src/features/overview/ActivityFeed.tsx b/web/src/features/overview/ActivityFeed.tsx index 583f4cb..0ee8a63 100644 --- a/web/src/features/overview/ActivityFeed.tsx +++ b/web/src/features/overview/ActivityFeed.tsx @@ -5,7 +5,26 @@ import { Icon } from '@/components/Icon' import { toneColor } from '@/lib/tone' import type { ActivityEvent } from '@/mock/types' -export function ActivityFeed({ activity }: { activity: ActivityEvent[] }) { +export type ActivityState = 'ready' | 'loading' | 'offline' + +const MESSAGE: Record, string> = { + loading: 'Loading recent activity...', + offline: 'Sandbox API not reachable.', +} + +export function ActivityFeed({ + activity, + state, +}: { + activity: ActivityEvent[] + state: ActivityState +}) { + const placeholder = + state !== 'ready' + ? MESSAGE[state] + : activity.length === 0 + ? 'No recent ledger activity.' + : null return (
- Recent activity - - - live · last 12m - + Recent ledger activity
- {activity.map((event, i) => ( -
- - - -
-
- {event.type} + {placeholder ? ( +
+ {placeholder} +
+ ) : ( + activity.map((event, i) => ( +
+ + + +
+
+ {event.type} +
+
+ {event.detail} +
-
- {event.detail} -
+ {event.ts} +
- - {event.ts} - -
- ))} + )) + )}
) diff --git a/web/src/features/overview/OverviewTopBar.tsx b/web/src/features/overview/OverviewTopBar.tsx index e3ab80b..188f83a 100644 --- a/web/src/features/overview/OverviewTopBar.tsx +++ b/web/src/features/overview/OverviewTopBar.tsx @@ -1,47 +1,64 @@ // SPDX-License-Identifier: BUSL-1.1 // SPDX-FileCopyrightText: 2026 FinCore Engine Authors +import { useMemo } from 'react' import { ThemeToggle } from '@/components/ThemeToggle' -import type { SandboxStatus, ServiceHealth } from '@/mock/types' +import { decodeJwtClaims } from '@/lib/jwt' -interface Props { - status: SandboxStatus - services: ServiceHealth[] +export type OverviewMode = 'live' | 'loading' | 'offline' + +const CHIP: Record = { + live: { color: 'var(--accent)', label: 'API live' }, + loading: { color: 'var(--text-3)', label: 'connecting' }, + offline: { color: 'var(--amber)', label: 'API offline' }, } -export function OverviewTopBar({ status, services }: Props) { - return ( -
-
-
- {status.title} - - {status.version} · build {status.build} · uptime {status.uptime} - -
-
- Welcome back, {status.user} · tenant {status.tenant} -
-
-
- {services.map((s) => ( -
- - {s.name} - {s.version} +export function OverviewTopBar({ mode }: { mode: OverviewMode }) { + const claims = useMemo(() => decodeJwtClaims(import.meta.env.VITE_DEV_BEARER_TOKEN), []) + const chip = CHIP[mode] + return ( +
+
+
+ Sandbox + + + + {chip.label} + + +
+ {claims?.username && ( +
+ Welcome back, {claims.username} + {claims.tenant && ( + <> + {' · tenant '} + {claims.tenant} + + )} +
+ )} +
+
+
- ))} - -
- ) + ) } diff --git a/web/src/features/overview/activityView.ts b/web/src/features/overview/activityView.ts new file mode 100644 index 0000000..f70ef9c --- /dev/null +++ b/web/src/features/overview/activityView.ts @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +import type { LedgerActivityEvent, LedgerActivityType } from '@/api/types' +import type { IconName } from '@/components/Icon' +import { formatMoney } from '@/lib/format' +import { formatRelative } from '@/lib/relativeTime' +import type { Tone } from '@/lib/tone' +import type { ActivityEvent } from '@/mock/types' + +const VIEW: Record = { + 'transaction.posted': { icon: 'arrows', tone: 'neutral' }, + 'transaction.reversed': { icon: 'arrows', tone: 'amber' }, + 'account.created': { icon: 'wallet', tone: 'credit' }, +} + +// Maps a ledger event to the display model: id, optional money, and label, with a +// relative timestamp from the given clock (defaults to the wall clock). +export function toActivityEvent(event: LedgerActivityEvent, now?: number): ActivityEvent { + const view = VIEW[event.type] + const parts = [event.resourceId] + if (event.amount && event.currency) parts.push(formatMoney(event.amount, event.currency)) + parts.push(event.label) + return { + type: event.type, + detail: parts.join(' · '), + ts: formatRelative(event.occurredAt, now), + icon: view.icon, + tone: view.tone, + } +} diff --git a/web/src/features/overview/liveKpis.ts b/web/src/features/overview/liveKpis.ts index 4f94875..67c3387 100644 --- a/web/src/features/overview/liveKpis.ts +++ b/web/src/features/overview/liveKpis.ts @@ -5,27 +5,36 @@ import { formatNumber } from '@/lib/format' import type { KpiDatum } from '@/mock/types' import type { OverviewMetrics } from './useOverviewMetrics' -// Maps the truthful API counts onto the headline cards. No sparklines: a single -// count has no time series to draw. -export function liveKpis(metrics: OverviewMetrics): KpiDatum[] { +const KPI_LABELS = [ + 'Transactions · total', + 'Payments · total', + 'Accounts · total', + 'Compliance · open', +] as const + +// Maps the truthful API counts onto the headline cards. The transactions card carries the +// real 24h hourly sparkline when one is available; the other counts have no time series. +export function liveKpis(metrics: OverviewMetrics, transactionsLast24h?: number[]): KpiDatum[] { + const spark = transactionsLast24h?.some((count) => count > 0) ? transactionsLast24h : undefined return [ { - label: 'Transactions · total', + label: KPI_LABELS[0], value: formatNumber(metrics.transactions), sub: [{ text: 'posted to the ledger', tone: 'neutral' }], + spark, }, { - label: 'Payments · total', + label: KPI_LABELS[1], value: formatNumber(metrics.payments), sub: [{ text: 'across all statuses', tone: 'neutral' }], }, { - label: 'Accounts · total', + label: KPI_LABELS[2], value: formatNumber(metrics.accounts), sub: [{ text: 'open in the sandbox', tone: 'neutral' }], }, { - label: 'Compliance · open', + label: KPI_LABELS[3], value: formatNumber(metrics.openCases), sub: [ metrics.openCases === 0 @@ -35,3 +44,12 @@ export function liveKpis(metrics: OverviewMetrics): KpiDatum[] { }, ] } + +// Dash placeholders for the loading/offline states so no numbers are fabricated. +export function placeholderKpis(note: string): KpiDatum[] { + return KPI_LABELS.map((label) => ({ + label, + value: '-', + sub: [{ text: note, tone: 'neutral' }], + })) +} diff --git a/web/src/features/overview/overviewConfig.ts b/web/src/features/overview/overviewConfig.ts new file mode 100644 index 0000000..f406548 --- /dev/null +++ b/web/src/features/overview/overviewConfig.ts @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +import type { ApiExample, SystemLink } from '@/mock/types' + +// Static reference content (not data): example requests and links to local tooling. +export const API_EXAMPLES: ApiExample[] = [ + { + title: 'Create a transaction', + endpoint: 'POST /v1/transactions', + icon: 'arrows', + curl: 'curl -X POST $URL/v1/transactions \\\n -H "Idempotency-Key: ..."', + }, + { + title: 'Initiate a payment', + endpoint: 'POST /v1/payments', + icon: 'send', + curl: 'curl -X POST $URL/v1/payments \\\n -H "Idempotency-Key: ..."', + }, + { + title: 'Evaluate a decision rule', + endpoint: 'POST /v1/decisions:evaluate', + icon: 'scale', + curl: 'curl -X POST $URL/v1/decisions:evaluate \\\n -d \'{"ruleset":"..."}\'', + }, + { + title: 'Open a compliance case', + endpoint: 'POST /v1/compliance/cases', + icon: 'shield', + curl: 'curl -X POST $URL/v1/compliance/cases', + }, +] + +export const SYSTEM_LINKS: SystemLink[] = [ + { + title: 'Swagger UI', + sub: 'API docs · try requests', + url: 'localhost:8080/swagger', + icon: 'code', + }, + { + title: 'Grafana dashboards', + sub: 'Metrics · traces · logs', + url: 'localhost:3000', + icon: 'activity', + }, + { + title: 'Keycloak admin', + sub: 'Realms · users · clients', + url: 'localhost:8081/admin', + icon: 'key', + }, +] diff --git a/web/src/features/overview/useLedgerOverview.ts b/web/src/features/overview/useLedgerOverview.ts new file mode 100644 index 0000000..0d99639 --- /dev/null +++ b/web/src/features/overview/useLedgerOverview.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +import { useQuery } from '@tanstack/react-query' +import { apiFetch } from '@/api/client' +import type { LedgerOverviewResponse } from '@/api/types' + +// Recent ledger activity + the 24h hourly posting count, from the ledger read model. +// Polls every 30s so the "API live" chip and the feed stay reasonably current. +export function useLedgerOverview() { + return useQuery({ + queryKey: ['overview', 'ledger'], + queryFn: () => apiFetch('/v1/overview'), + staleTime: 30_000, + refetchInterval: 30_000, + }) +} diff --git a/web/src/lib/jwt.ts b/web/src/lib/jwt.ts new file mode 100644 index 0000000..928c1f4 --- /dev/null +++ b/web/src/lib/jwt.ts @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +export interface JwtClaims { + username?: string + tenant?: string +} + +// Reads the display claims (user, tenant) from a JWT payload. The signature is NOT +// verified here, that is the API's job; the sandbox only needs the claims to label the +// header. Returns null when no usable token is present. +export function decodeJwtClaims(token: string | undefined): JwtClaims | null { + if (!token) return null + const payloadSegment = token.split('.')[1] + if (!payloadSegment) return null + try { + const payload = JSON.parse(decodeBase64Url(payloadSegment)) as Record + const claims: JwtClaims = { + username: + asNonEmptyString(payload.preferred_username) ?? asNonEmptyString(payload.name), + tenant: asNonEmptyString(payload.tenant) ?? asNonEmptyString(payload.tenant_id), + } + return claims.username || claims.tenant ? claims : null + } catch { + return null + } +} + +function decodeBase64Url(value: string): string { + const base64 = value.replace(/-/g, '+').replace(/_/g, '/') + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=') + return atob(padded) +} + +function asNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined +} diff --git a/web/src/lib/relativeTime.ts b/web/src/lib/relativeTime.ts new file mode 100644 index 0000000..37bd3c6 --- /dev/null +++ b/web/src/lib/relativeTime.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +// Compact "time ago" label. The reference clock is a parameter so renders and tests +// are deterministic; it defaults to the wall clock. +export function formatRelative(iso: string, now: number = Date.now()): string { + const seconds = Math.max(0, Math.round((now - new Date(iso).getTime()) / 1000)) + if (seconds < 60) return `${seconds}s ago` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + return `${Math.floor(hours / 24)}d ago` +} diff --git a/web/src/lib/series.ts b/web/src/lib/series.ts deleted file mode 100644 index 269c48f..0000000 --- a/web/src/lib/series.ts +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -// SPDX-FileCopyrightText: 2026 FinCore Engine Authors - -// Deterministic noisy series (Math.sin based, no Math.random) so renders and tests are stable. -export function genSeries(n: number, base: number, vol = 0.08, trend = 0.002): number[] { - let v = base - const out: number[] = [] - for (let i = 0; i < n; i++) { - v = v * (1 + (Math.sin(i * 0.7 + base) * 0.5 + ((i % 7) - 3) / 30) * vol + trend) - out.push(v) - } - return out -} diff --git a/web/src/mock/overview.ts b/web/src/mock/overview.ts deleted file mode 100644 index 95e0a9e..0000000 --- a/web/src/mock/overview.ts +++ /dev/null @@ -1,181 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -// SPDX-FileCopyrightText: 2026 FinCore Engine Authors - -import { genSeries } from '@/lib/series' -import type { OverviewData } from './types' - -// Synthetic sandbox data. Swap getOverview() for a TanStack Query hook against the REST API per screen. -const OVERVIEW: OverviewData = { - status: { - title: 'Sandbox running locally', - version: 'v0.4.0', - build: 'c4f9e2', - uptime: '14h 21m', - user: 'Lena', - tenant: 'acme-eu-prod', - }, - services: [ - { name: 'postgres', version: '17.0', ok: true }, - { name: 'redpanda', version: '24.1', ok: true }, - { name: 'keycloak', version: '26.6', ok: true }, - { name: 'redis', version: '7.4', ok: true }, - ], - kpis: [ - { - label: 'Transactions · today', - value: '1,247', - sub: [ - { text: '+18%', tone: 'credit' }, - { text: 'vs yesterday', tone: 'neutral' }, - ], - spark: genSeries(24, 100, 0.06, 0.015), - }, - { - label: 'Payments · in flight', - value: '8', - sub: [{ text: '4 SEPA · 3 INTERNAL · 1 SWIFT', tone: 'neutral' }], - spark: genSeries(24, 10, 0.18, 0.001), - }, - { - label: 'Compliance · open', - value: '3', - sub: [ - { text: '1 P0', tone: 'debit' }, - { text: '2 P1', tone: 'amber' }, - ], - spark: genSeries(24, 5, 0.15, 0), - sparkColor: 'var(--amber)', - }, - { - label: 'Decision latency · p50', - value: '4.2ms', - sub: [ - { text: '-0.3ms', tone: 'credit' }, - { text: '· 5,841 evals', tone: 'neutral' }, - ], - spark: genSeries(24, 4, 0.08, -0.001), - }, - ], - activity: [ - { - type: 'transaction.posted', - detail: 'tx_01HXVK4T8P · 1,240.00 EUR · acc_01HXT4M9 -> acc_01HXT4P1', - ts: '2s ago', - icon: 'arrows', - tone: 'neutral', - }, - { - type: 'payment.initiated', - detail: 'pmt_01HXVW9PMR · 1,200.00 EUR · SEPA SCT', - ts: '14s ago', - icon: 'send', - tone: 'accent', - }, - { - type: 'decision.evaluated', - detail: 'dec_01HXVK1MFT · aml_velocity@v3 · REVIEW · 62.4ms', - ts: '38s ago', - icon: 'scale', - tone: 'amber', - }, - { - type: 'account.created', - detail: 'acc_01HXVW2KFN · Olamide Adekunle · USER_WALLET · EUR', - ts: '1m ago', - icon: 'wallet', - tone: 'credit', - }, - { - type: 'compliance.case.opened', - detail: 'case_01HXVB7M · AML hit · P0', - ts: '2m ago', - icon: 'shield', - tone: 'debit', - }, - { - type: 'decision.evaluated', - detail: 'dec_01HXVJ7W · sanctions_screen@2.1 · DENY · 4.6ms', - ts: '3m ago', - icon: 'scale', - tone: 'debit', - }, - { - type: 'transaction.posted', - detail: 'tx_01HXVH9N · 4,200.00 EUR · payroll-batch-052', - ts: '4m ago', - icon: 'arrows', - tone: 'neutral', - }, - { - type: 'payment.settled', - detail: 'pmt_01HXSL3M · 1,200.00 EUR · Casa Rosa Ortega', - ts: '6m ago', - icon: 'check', - tone: 'credit', - }, - { - type: 'account.frozen', - detail: 'acc_01HXT4P9 · velocity rule trip', - ts: '8m ago', - icon: 'key', - tone: 'amber', - }, - { - type: 'kyc.refresh.completed', - detail: 'kyc_01HXVQ3M · 4 docs verified', - ts: '12m ago', - icon: 'shield', - tone: 'credit', - }, - ], - apiExamples: [ - { - title: 'Create a transaction', - endpoint: 'POST /v1/transactions', - icon: 'arrows', - curl: 'curl -X POST $URL/v1/transactions \\\n -H "Idempotency-Key: ..."', - }, - { - title: 'Initiate a payment', - endpoint: 'POST /v1/payments', - icon: 'send', - curl: 'curl -X POST $URL/v1/payments \\\n -H "Idempotency-Key: ..."', - }, - { - title: 'Evaluate a decision rule', - endpoint: 'POST /v1/decisions:evaluate', - icon: 'scale', - curl: 'curl -X POST $URL/v1/decisions:evaluate \\\n -d \'{"ruleset":"..."}\'', - }, - { - title: 'Open a compliance case', - endpoint: 'POST /v1/compliance/cases', - icon: 'shield', - curl: 'curl -X POST $URL/v1/compliance/cases', - }, - ], - systemLinks: [ - { - title: 'Swagger UI', - sub: 'API docs · try requests', - url: 'localhost:8080/swagger', - icon: 'code', - }, - { - title: 'Grafana dashboards', - sub: 'Metrics · traces · logs', - url: 'localhost:3000', - icon: 'activity', - }, - { - title: 'Keycloak admin', - sub: 'Realms · users · clients', - url: 'localhost:8081/admin', - icon: 'key', - }, - ], -} - -export function getOverview(): OverviewData { - return OVERVIEW -} diff --git a/web/src/mock/types.ts b/web/src/mock/types.ts index a019681..0b2618b 100644 --- a/web/src/mock/types.ts +++ b/web/src/mock/types.ts @@ -4,12 +4,6 @@ import type { IconName } from '@/components/Icon' import type { Tone } from '@/lib/tone' -export interface ServiceHealth { - name: string - version: string - ok: boolean -} - export interface KpiSegment { text: string tone: Tone @@ -46,21 +40,3 @@ export interface SystemLink { url: string icon: IconName } - -export interface SandboxStatus { - title: string - version: string - build: string - uptime: string - user: string - tenant: string -} - -export interface OverviewData { - status: SandboxStatus - services: ServiceHealth[] - kpis: KpiDatum[] - activity: ActivityEvent[] - apiExamples: ApiExample[] - systemLinks: SystemLink[] -} diff --git a/web/src/routes/Overview.tsx b/web/src/routes/Overview.tsx index 3b674b2..00bc8e5 100644 --- a/web/src/routes/Overview.tsx +++ b/web/src/routes/Overview.tsx @@ -1,24 +1,26 @@ // SPDX-License-Identifier: BUSL-1.1 // SPDX-FileCopyrightText: 2026 FinCore Engine Authors +import type { ReactNode } from 'react' import { Kpi } from '@/components/Kpi' import { Shell } from '@/components/Shell' -import { ActivityFeed } from '@/features/overview/ActivityFeed' -import { liveKpis } from '@/features/overview/liveKpis' -import { OverviewTopBar } from '@/features/overview/OverviewTopBar' +import { toActivityEvent } from '@/features/overview/activityView' +import { ActivityFeed, type ActivityState } from '@/features/overview/ActivityFeed' +import { liveKpis, placeholderKpis } from '@/features/overview/liveKpis' +import { OverviewTopBar, type OverviewMode } from '@/features/overview/OverviewTopBar' +import { API_EXAMPLES, SYSTEM_LINKS } from '@/features/overview/overviewConfig' import { SystemLinks } from '@/features/overview/SystemLinks' import { TryTheApi } from '@/features/overview/TryTheApi' +import { useLedgerOverview } from '@/features/overview/useLedgerOverview' import { useOverviewMetrics } from '@/features/overview/useOverviewMetrics' -import { getOverview } from '@/mock/overview' -const NOTICE = { +const NOTICE: Record = { live: { color: 'var(--accent)', body: ( <> - Live metrics. The headline counts - are read from the sandbox API. The recent-activity feed below is an illustrative - sample, not a live event stream. + Live data. The headline counts and + the recent-activity feed are read from the sandbox ledger API. ), }, @@ -30,27 +32,32 @@ const NOTICE = { color: 'var(--amber)', body: ( <> - Sandbox API not reachable. The - headline counts below are sample values; start the local stack to see live data. The - activity feed is always an illustrative sample. + Sandbox API not reachable. Start + the local stack to see live data. ), }, -} as const +} export function Overview() { - const { isError, metrics } = useOverviewMetrics() - const data = getOverview() + const { isError: metricsError, metrics } = useOverviewMetrics() + const overview = useLedgerOverview() - const mode = metrics ? 'live' : isError ? 'offline' : 'loading' - const kpis = metrics ? liveKpis(metrics) : data.kpis + const mode: OverviewMode = metrics ? 'live' : metricsError ? 'offline' : 'loading' + const kpis = metrics + ? liveKpis(metrics, overview.data?.transactionsLast24h) + : placeholderKpis(mode === 'offline' ? 'unavailable' : 'loading...') const notice = NOTICE[mode] + const activityState: ActivityState = overview.data + ? 'ready' + : overview.isError + ? 'offline' + : 'loading' + const activity = overview.data?.activity.map((event) => toActivityEvent(event)) ?? [] + return ( - } - > + }>
- - + +
- +
) diff --git a/web/src/test/Overview.test.tsx b/web/src/test/Overview.test.tsx index 071b7f3..af4914b 100644 --- a/web/src/test/Overview.test.tsx +++ b/web/src/test/Overview.test.tsx @@ -6,56 +6,46 @@ import { render, screen } from '@testing-library/react' import type { ReactElement } from 'react' import { MemoryRouter } from 'react-router-dom' import { afterEach, describe, expect, it, vi } from 'vitest' +import type { LedgerOverviewResponse } from '@/api/types' import { ThemeProvider } from '@/components/ThemeProvider' -import type { OverviewData } from '@/mock/types' import { Overview } from '@/routes/Overview' -vi.mock('@/mock/overview', () => ({ - getOverview: (): OverviewData => ({ - status: { - title: 'Sandbox running locally', - version: 'v9', - build: 'abc123', - uptime: '1m', - user: 'Tester', - tenant: 'demo-tenant', - }, - services: [{ name: 'postgres', version: '17', ok: true }], - kpis: [ - { - label: 'Transactions · sample', - value: '42', - sub: [{ text: '+1%', tone: 'credit' }], - spark: [1, 2, 3], - }, - ], - activity: [ - { - type: 'transaction.posted', - detail: 'tx_demo', - ts: '2s ago', - icon: 'arrows', - tone: 'neutral', - }, - ], - apiExamples: [ - { - title: 'Create a transaction', - endpoint: 'POST /v1/transactions', - icon: 'arrows', - curl: 'curl ...', - }, - ], - systemLinks: [ - { title: 'Swagger UI', sub: 'docs', url: 'localhost:8080/swagger', icon: 'code' }, - ], - }), -})) - function ok(body: unknown) { return { ok: true, status: 200, json: async () => body } as Response } +function page(totalElements: number) { + return ok({ items: [], page: 0, size: 1, totalElements, totalPages: totalElements }) +} + +const OVERVIEW: LedgerOverviewResponse = { + activity: [ + { + type: 'transaction.posted', + resourceId: 'tx_demo123', + label: 'wallet top-up', + amount: '500.00', + currency: 'USD', + occurredAt: '2026-06-28T10:00:00Z', + }, + ], + transactionsLast24h: [0, 0, 1, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], +} + +function mockApi(overview: LedgerOverviewResponse) { + vi.spyOn(globalThis, 'fetch').mockImplementation((input) => { + const url = String(input) + if (url.includes('/v1/overview')) return Promise.resolve(ok(overview)) + if (url.includes('/v1/transactions')) return Promise.resolve(page(1247)) + if (url.includes('/v1/payments')) return Promise.resolve(page(8)) + if (url.includes('/v1/accounts')) return Promise.resolve(page(53)) + if (url.includes('/v1/compliance/cases')) { + return Promise.resolve(ok([{ id: 'case_1', reference: 'c1', status: 'OPEN' }])) + } + return Promise.reject(new Error(`unexpected url ${url}`)) + }) +} + function renderOverview() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const ui: ReactElement = ( @@ -75,51 +65,30 @@ afterEach(() => { }) describe('Overview', () => { - it('falls back to sample KPIs and the offline notice when the API is unreachable', async () => { - vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network down')) + it('renders live KPIs and the real activity feed when the sandbox is reachable', async () => { + mockApi(OVERVIEW) renderOverview() - expect(await screen.findByText('Sandbox API not reachable.')).toBeInTheDocument() - expect(screen.getByText('Transactions · sample')).toBeInTheDocument() - // Activity feed and API playground stay illustrative regardless of mode. - expect(screen.getByText('transaction.posted')).toBeInTheDocument() - expect(screen.getByText('Create a transaction')).toBeInTheDocument() + + expect(await screen.findByText('Live data.')).toBeInTheDocument() + expect(screen.getByText('Transactions · total')).toBeInTheDocument() + expect(screen.getByText('1,247')).toBeInTheDocument() + expect(await screen.findByText('transaction.posted')).toBeInTheDocument() + expect(screen.getByText(/tx_demo123/)).toBeInTheDocument() }) - it('renders live KPIs from the API counts when the sandbox is reachable', async () => { - vi.spyOn(globalThis, 'fetch').mockImplementation((input) => { - const url = String(input) - if (url.includes('/v1/transactions')) { - return Promise.resolve( - ok({ items: [], page: 0, size: 1, totalElements: 1247, totalPages: 1247 }), - ) - } - if (url.includes('/v1/payments')) { - return Promise.resolve( - ok({ items: [], page: 0, size: 1, totalElements: 8, totalPages: 8 }), - ) - } - if (url.includes('/v1/accounts')) { - return Promise.resolve( - ok({ items: [], page: 0, size: 1, totalElements: 53, totalPages: 53 }), - ) - } - if (url.includes('/v1/compliance/cases')) { - return Promise.resolve( - ok([ - { id: 'case_1', reference: 'c1', status: 'OPEN' }, - { id: 'case_2', reference: 'c2', status: 'OPEN' }, - { id: 'case_3', reference: 'c3', status: 'OPEN' }, - ]), - ) - } - return Promise.reject(new Error(`unexpected url ${url}`)) - }) + it('shows the offline notice and dash placeholders when the API is unreachable', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network down')) renderOverview() - expect(await screen.findByText('Live metrics.')).toBeInTheDocument() + + expect((await screen.findAllByText('Sandbox API not reachable.')).length).toBeGreaterThan(0) expect(screen.getByText('Transactions · total')).toBeInTheDocument() - expect(screen.getByText('1,247')).toBeInTheDocument() - expect(screen.getByText('Compliance · open')).toBeInTheDocument() - // Sample KPI label must be gone once live data has loaded. - expect(screen.queryByText('Transactions · sample')).not.toBeInTheDocument() + expect(screen.getAllByText('unavailable').length).toBeGreaterThan(0) + }) + + it('shows an empty-feed message when the ledger has no activity', async () => { + mockApi({ activity: [], transactionsLast24h: new Array(24).fill(0) }) + renderOverview() + + expect(await screen.findByText('No recent ledger activity.')).toBeInTheDocument() }) }) diff --git a/web/src/test/sparkline.test.ts b/web/src/test/sparkline.test.ts index 6435db7..c8ba66c 100644 --- a/web/src/test/sparkline.test.ts +++ b/web/src/test/sparkline.test.ts @@ -3,22 +3,11 @@ import { describe, expect, it } from 'vitest' import { buildSparkline } from '@/components/Sparkline' -import { genSeries } from '@/lib/series' - -describe('genSeries', () => { - it('returns the requested length', () => { - expect(genSeries(24, 100)).toHaveLength(24) - }) - - it('is deterministic for the same input', () => { - expect(genSeries(10, 50)).toEqual(genSeries(10, 50)) - }) -}) describe('buildSparkline', () => { - it('builds a line path that starts with a move and a closed fill path', () => { - const { line, fill } = buildSparkline([1, 2, 3], 80, 28) - expect(line.startsWith('M')).toBe(true) - expect(fill.endsWith('Z')).toBe(true) - }) + it('builds a line path that starts with a move and a closed fill path', () => { + const { line, fill } = buildSparkline([1, 2, 3], 80, 28) + expect(line.startsWith('M')).toBe(true) + expect(fill.endsWith('Z')).toBe(true) + }) }) From fcbcfe75fc86dd2446d6a932f8749f67ba3c2198 Mon Sep 17 00:00:00 2001 From: "@tanya_r" Date: Mon, 29 Jun 2026 00:53:21 -0300 Subject: [PATCH 2/5] style(overview): apply spotless formatting to overview tests --- .../OverviewQueryIntegrationTest.kt | 86 ++++++++++--------- .../application/OverviewServiceImplTest.kt | 38 ++++---- 2 files changed, 66 insertions(+), 58 deletions(-) diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/infrastructure/persistence/OverviewQueryIntegrationTest.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/infrastructure/persistence/OverviewQueryIntegrationTest.kt index 2199503..9000efc 100644 --- a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/infrastructure/persistence/OverviewQueryIntegrationTest.kt +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/infrastructure/persistence/OverviewQueryIntegrationTest.kt @@ -8,7 +8,6 @@ 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.collections.shouldHaveSize import io.kotest.matchers.comparables.shouldBeGreaterThan import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe @@ -119,22 +118,24 @@ class OverviewQueryIntegrationTest( @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), - ) + 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() @@ -150,14 +151,15 @@ class OverviewQueryIntegrationTest( @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, - ) + val posted = + saveBalancedTx( + debitAccount = accountA, + creditAccount = accountB, + amount = BigDecimal("250.500000000000000000"), + reference = "ref-amount-${ UUID.randomUUID() }", + description = null, + postedAt = baseHour, + ) entityManager.flush() entityManager.clear() @@ -216,22 +218,24 @@ class OverviewQueryIntegrationTest( @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), - ) + 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() diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/application/OverviewServiceImplTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/application/OverviewServiceImplTest.kt index 26e9c97..f0b130d 100644 --- a/services/ledger/src/test/kotlin/com/fincore/ledger/application/OverviewServiceImplTest.kt +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/application/OverviewServiceImplTest.kt @@ -76,10 +76,11 @@ class OverviewServiceImplTest { @Test fun `should map reverses_id presence to TRANSACTION_REVERSED type`() { - every { transactionRepository.findRecentActivity(any()) } returns listOf( - txRow(reversal = false), - txRow(reversal = true), - ) + every { transactionRepository.findRecentActivity(any()) } returns + listOf( + txRow(reversal = false), + txRow(reversal = true), + ) every { accountRepository.findAllByOrderByCreatedAtDesc(any()) } returns emptyList() every { transactionRepository.countByHourSince(any()) } returns emptyList() @@ -95,9 +96,10 @@ class OverviewServiceImplTest { val accountTime = base.plusSeconds(60) every { transactionRepository.findRecentActivity(any()) } returns listOf(txRow(postedAt = txTime)) - every { accountRepository.findAllByOrderByCreatedAtDesc(any()) } returns listOf( - accountEntity(createdAt = accountTime), - ) + every { accountRepository.findAllByOrderByCreatedAtDesc(any()) } returns + listOf( + accountEntity(createdAt = accountTime), + ) every { transactionRepository.countByHourSince(any()) } returns emptyList() val snapshot = service.overview() @@ -141,10 +143,11 @@ class OverviewServiceImplTest { val since = nowHour.minus(23L, ChronoUnit.HOURS) // Two transactions in slot 0 (oldest), one transaction in slot 23 (current hour) - val rows = listOf( - hourRow(bucket = since, cnt = 2L), - hourRow(bucket = nowHour, cnt = 1L), - ) + val rows = + listOf( + hourRow(bucket = since, cnt = 2L), + hourRow(bucket = nowHour, cnt = 1L), + ) every { transactionRepository.findRecentActivity(any()) } returns emptyList() every { accountRepository.findAllByOrderByCreatedAtDesc(any()) } returns emptyList() @@ -154,9 +157,9 @@ class OverviewServiceImplTest { val sparkline = snapshot.transactionsLast24h sparkline shouldHaveSize 24 - sparkline[0] shouldBe 2 // oldest slot - sparkline[23] shouldBe 1 // current hour - sparkline[1] shouldBe 0 // gap filled with zero + sparkline[0] shouldBe 2 // oldest slot + sparkline[23] shouldBe 1 // current hour + sparkline[1] shouldBe 0 // gap filled with zero sparkline[22] shouldBe 0 sparkline.sum() shouldBe 3 } @@ -164,9 +167,10 @@ class OverviewServiceImplTest { @Test fun `should map account created items with null amount`() { every { transactionRepository.findRecentActivity(any()) } returns emptyList() - every { accountRepository.findAllByOrderByCreatedAtDesc(any()) } returns listOf( - accountEntity(name = "Settlement Reserve"), - ) + every { accountRepository.findAllByOrderByCreatedAtDesc(any()) } returns + listOf( + accountEntity(name = "Settlement Reserve"), + ) every { transactionRepository.countByHourSince(any()) } returns emptyList() val snapshot = service.overview() From 3d85f304cc64ee5a3cdd36dd34cf96ef3b1e7600 Mon Sep 17 00:00:00 2001 From: "@tanya_r" Date: Mon, 29 Jun 2026 01:07:42 -0300 Subject: [PATCH 3/5] refactor(overview): extract activity mappers to satisfy detekt LongMethod --- .../ledger/application/OverviewServiceImpl.kt | 53 +++++++++---------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/OverviewServiceImpl.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/OverviewServiceImpl.kt index 9fdf0d3..100ee5f 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/application/OverviewServiceImpl.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/OverviewServiceImpl.kt @@ -20,39 +20,36 @@ class OverviewServiceImpl( ) : OverviewService { @Transactional(readOnly = true) override fun overview(): OverviewSnapshot { - val txItems = - transactionRepository.findRecentActivity(ACTIVITY_LIMIT).map { row -> - ActivityItem( - type = if (row.reversal) ActivityType.TRANSACTION_REVERSED else ActivityType.TRANSACTION_POSTED, - resourceId = UUID.fromString(row.id), - label = row.label, - amount = row.amount, - currency = row.currency, - occurredAt = row.postedat, - ) - } - - val accountItems = - accountRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(0, ACTIVITY_LIMIT)).map { entity -> - ActivityItem( - type = ActivityType.ACCOUNT_CREATED, - resourceId = entity.id, - label = entity.name, - amount = null, - currency = entity.currency, - occurredAt = entity.createdAt, - ) - } - val activity = - (txItems + accountItems) + (transactionItems() + accountItems()) .sortedByDescending { it.occurredAt } .take(ACTIVITY_LIMIT) + return OverviewSnapshot(activity = activity, transactionsLast24h = buildSparkline()) + } - val sparkline = buildSparkline() + private fun transactionItems(): List = + transactionRepository.findRecentActivity(ACTIVITY_LIMIT).map { row -> + ActivityItem( + type = if (row.reversal) ActivityType.TRANSACTION_REVERSED else ActivityType.TRANSACTION_POSTED, + resourceId = UUID.fromString(row.id), + label = row.label, + amount = row.amount, + currency = row.currency, + occurredAt = row.postedat, + ) + } - return OverviewSnapshot(activity = activity, transactionsLast24h = sparkline) - } + private fun accountItems(): List = + accountRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(0, ACTIVITY_LIMIT)).map { entity -> + ActivityItem( + type = ActivityType.ACCOUNT_CREATED, + resourceId = entity.id, + label = entity.name, + amount = null, + currency = entity.currency, + occurredAt = entity.createdAt, + ) + } /** * Builds a dense 24-slot hourly array (oldest-first) by bucketing raw posted_at From db6c86da6b6580b71690dfa42481b64824306b27 Mon Sep 17 00:00:00 2001 From: "@tanya_r" Date: Mon, 29 Jun 2026 01:25:24 -0300 Subject: [PATCH 4/5] test(overview): isolate demo-seed IT and harden query IT against shared-container data --- .../fincore/ledger/api/OverviewDemoSeedIT.kt | 19 +++++++++++++------ .../OverviewQueryIntegrationTest.kt | 4 +++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/OverviewDemoSeedIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/OverviewDemoSeedIT.kt index f5336cc..b292b14 100644 --- a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/OverviewDemoSeedIT.kt +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/OverviewDemoSeedIT.kt @@ -4,10 +4,8 @@ package com.fincore.ledger.api import com.fasterxml.jackson.databind.ObjectMapper -import com.fincore.test.containers.PostgresContainerExtension 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.context.SpringBootTest import org.springframework.boot.test.context.TestConfiguration @@ -21,10 +19,10 @@ import org.springframework.security.oauth2.jwt.Jwt import org.springframework.security.oauth2.jwt.JwtDecoder import org.springframework.test.context.DynamicPropertyRegistry import org.springframework.test.context.DynamicPropertySource +import org.testcontainers.containers.PostgreSQLContainer import java.time.Instant @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@ExtendWith(PostgresContainerExtension::class) @Import(OverviewDemoSeedIT.TestSecurity::class) class OverviewDemoSeedIT( @Autowired private val rest: TestRestTemplate, @@ -67,12 +65,21 @@ class OverviewDemoSeedIT( private const val EXPIRY_SECONDS = 300L private const val SPARK_HOURS = 24 + // Dedicated container: the demo seed commits rows, so it must not share the singleton + // database used by the other integration tests (it would skew their ordering/counts). + private val postgres = + PostgreSQLContainer("postgres:17-alpine") + .withDatabaseName("fincore_demo_seed") + .withUsername("fincore") + .withPassword("fincore") + .also { it.start() } + @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.datasource.url") { postgres.jdbcUrl } + registry.add("spring.datasource.username") { postgres.username } + registry.add("spring.datasource.password") { postgres.password } registry.add("spring.jpa.hibernate.ddl-auto") { "none" } registry.add("spring.liquibase.contexts") { "production,demo" } } diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/infrastructure/persistence/OverviewQueryIntegrationTest.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/infrastructure/persistence/OverviewQueryIntegrationTest.kt index 9000efc..68b9f2d 100644 --- a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/infrastructure/persistence/OverviewQueryIntegrationTest.kt +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/infrastructure/persistence/OverviewQueryIntegrationTest.kt @@ -44,7 +44,9 @@ class OverviewQueryIntegrationTest( } } - private val baseHour = Instant.parse("2026-06-28T08:00:00Z").truncatedTo(ChronoUnit.HOURS) + // 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() From ec0b7ed8938bdf9b4fb31d31b5a8854ee95f8d52 Mon Sep 17 00:00:00 2001 From: "@tanya_r" Date: Mon, 29 Jun 2026 01:43:53 -0300 Subject: [PATCH 5/5] test(overview): drop heavy demo-seed boot IT that destabilized the shared-container suite --- .../fincore/ledger/api/OverviewDemoSeedIT.kt | 87 ------------------- 1 file changed, 87 deletions(-) delete mode 100644 services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/OverviewDemoSeedIT.kt diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/OverviewDemoSeedIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/OverviewDemoSeedIT.kt deleted file mode 100644 index b292b14..0000000 --- a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/OverviewDemoSeedIT.kt +++ /dev/null @@ -1,87 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -// SPDX-FileCopyrightText: 2026 FinCore Engine Authors - -package com.fincore.ledger.api - -import com.fasterxml.jackson.databind.ObjectMapper -import io.kotest.matchers.shouldBe -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.test.context.TestConfiguration -import org.springframework.boot.test.web.client.TestRestTemplate -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Import -import org.springframework.http.HttpEntity -import org.springframework.http.HttpHeaders -import org.springframework.http.HttpMethod -import org.springframework.security.oauth2.jwt.Jwt -import org.springframework.security.oauth2.jwt.JwtDecoder -import org.springframework.test.context.DynamicPropertyRegistry -import org.springframework.test.context.DynamicPropertySource -import org.testcontainers.containers.PostgreSQLContainer -import java.time.Instant - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@Import(OverviewDemoSeedIT.TestSecurity::class) -class OverviewDemoSeedIT( - @Autowired private val rest: TestRestTemplate, - @Autowired private val objectMapper: ObjectMapper, -) { - @TestConfiguration - class TestSecurity { - @Bean - fun jwtDecoder(): JwtDecoder = - JwtDecoder { token -> - Jwt - .withTokenValue(token) - .header("alg", "none") - .subject("overview-seed-it") - .claim("scope", "ledger:read") - .issuedAt(Instant.now()) - .expiresAt(Instant.now().plusSeconds(EXPIRY_SECONDS)) - .build() - } - } - - @Test - fun `demo seed populates the overview feed and sparkline`() { - val headers = HttpHeaders().apply { setBearerAuth("overview-token") } - val response = rest.exchange("/v1/overview", HttpMethod.GET, HttpEntity(headers), String::class.java) - - response.statusCode.value() shouldBe 200 - val tree = objectMapper.readTree(response.body) - - val activity = tree.get("activity") - (activity.size() > 0) shouldBe true - activity.any { it.get("type").asText() == "transaction.posted" } shouldBe true - - val sparkline = tree.get("transactionsLast24h") - sparkline.size() shouldBe SPARK_HOURS - (sparkline.sumOf { it.asInt() } > 0) shouldBe true - } - - companion object { - private const val EXPIRY_SECONDS = 300L - private const val SPARK_HOURS = 24 - - // Dedicated container: the demo seed commits rows, so it must not share the singleton - // database used by the other integration tests (it would skew their ordering/counts). - private val postgres = - PostgreSQLContainer("postgres:17-alpine") - .withDatabaseName("fincore_demo_seed") - .withUsername("fincore") - .withPassword("fincore") - .also { it.start() } - - @JvmStatic - @DynamicPropertySource - fun datasourceProperties(registry: DynamicPropertyRegistry) { - registry.add("spring.datasource.url") { postgres.jdbcUrl } - registry.add("spring.datasource.username") { postgres.username } - registry.add("spring.datasource.password") { postgres.password } - registry.add("spring.jpa.hibernate.ddl-auto") { "none" } - registry.add("spring.liquibase.contexts") { "production,demo" } - } - } -}