From 8e498960e3731c7059b1f2a7ee6579d24f0c4364 Mon Sep 17 00:00:00 2001 From: "@tanya_r" Date: Sat, 13 Jun 2026 21:24:56 -0300 Subject: [PATCH 1/2] feat(ledger): add rest api for accounts and transactions Thin controllers over the existing application services: create, get and balance for accounts and post for transactions. Adds request/response DTOs with bean validation, an Idempotency-Key servlet filter that reuses IdempotencyService, RFC 7807 problem responses mapping domain conflicts to 409 and invariant violations to 422, JWT resource-server security with the subject as actor, and a served OpenAPI document. Transaction get and reverse are deferred to #105 (need new service methods). Closes #35 Closes #36 Closes #37 Closes #40 Closes #43 --- gradle/libs.versions.toml | 1 + services/ledger/build.gradle.kts | 1 + .../fincore/ledger/api/LedgerApiSmokeIT.kt | 96 +++++++++ .../fincore/ledger/api/AccountController.kt | 84 ++++++++ .../ledger/api/TransactionController.kt | 69 ++++++ .../api/dto/request/CreateAccountRequest.kt | 21 ++ .../api/dto/request/PostTransactionRequest.kt | 37 ++++ .../api/dto/response/AccountResponse.kt | 15 ++ .../api/dto/response/BalanceResponse.kt | 14 ++ .../api/dto/response/TransactionResponse.kt | 12 ++ .../CachedBodyHttpServletRequest.kt | 39 ++++ .../api/idempotency/IdempotencyAttributes.kt | 10 + .../api/idempotency/IdempotencyFilter.kt | 65 ++++++ .../ledger/api/mapper/LedgerApiMapper.kt | 80 +++++++ .../fincore/ledger/config/OpenApiConfig.kt | 26 +++ .../fincore/ledger/config/SecurityConfig.kt | 40 ++++ .../exception/GlobalExceptionHandler.kt | 118 +++++++++++ .../ledger/src/main/resources/application.yml | 13 ++ .../ledger/api/AccountControllerTest.kt | 198 ++++++++++++++++++ .../ledger/api/FakeIdempotencyService.kt | 32 +++ .../ledger/api/TransactionControllerTest.kt | 149 +++++++++++++ .../api/idempotency/IdempotencyFilterTest.kt | 79 +++++++ .../ledger/api/mapper/LedgerApiMapperTest.kt | 92 ++++++++ 23 files changed, 1291 insertions(+) create mode 100644 services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/LedgerApiSmokeIT.kt create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/api/AccountController.kt create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/api/TransactionController.kt create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/request/CreateAccountRequest.kt create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/request/PostTransactionRequest.kt create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/response/AccountResponse.kt create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/response/BalanceResponse.kt create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/response/TransactionResponse.kt create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/api/idempotency/CachedBodyHttpServletRequest.kt create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/api/idempotency/IdempotencyAttributes.kt create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/api/idempotency/IdempotencyFilter.kt create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/api/mapper/LedgerApiMapper.kt create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/config/OpenApiConfig.kt create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/config/SecurityConfig.kt create mode 100644 services/ledger/src/main/kotlin/com/fincore/ledger/exception/GlobalExceptionHandler.kt create mode 100644 services/ledger/src/main/resources/application.yml create mode 100644 services/ledger/src/test/kotlin/com/fincore/ledger/api/AccountControllerTest.kt create mode 100644 services/ledger/src/test/kotlin/com/fincore/ledger/api/FakeIdempotencyService.kt create mode 100644 services/ledger/src/test/kotlin/com/fincore/ledger/api/TransactionControllerTest.kt create mode 100644 services/ledger/src/test/kotlin/com/fincore/ledger/api/idempotency/IdempotencyFilterTest.kt create mode 100644 services/ledger/src/test/kotlin/com/fincore/ledger/api/mapper/LedgerApiMapperTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5454648..910d2d4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,6 +36,7 @@ spring-boot-starter-oauth2-resource-server = { module = "org.springframework.boo spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "spring-boot" } spring-boot-starter-validation = { module = "org.springframework.boot:spring-boot-starter-validation", version.ref = "spring-boot" } spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "spring-boot" } +spring-security-test = { module = "org.springframework.security:spring-security-test" } # Hibernate hibernate-core = { module = "org.hibernate.orm:hibernate-core", version.ref = "hibernate" } diff --git a/services/ledger/build.gradle.kts b/services/ledger/build.gradle.kts index 66013d6..b7ec7f7 100644 --- a/services/ledger/build.gradle.kts +++ b/services/ledger/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { testImplementation(project(":libs:fincore-test-support")) testImplementation(libs.spring.boot.starter.test) + testImplementation(libs.spring.security.test) testImplementation(libs.kotest.assertions.core) testImplementation(libs.kotest.property) testImplementation(libs.kotest.runner.junit5) diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/LedgerApiSmokeIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/LedgerApiSmokeIT.kt new file mode 100644 index 0000000..b0cdebb --- /dev/null +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/LedgerApiSmokeIT.kt @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.api + +import com.fincore.ledger.api.idempotency.IdempotencyAttributes +import com.fincore.test.containers.PostgresContainerExtension +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +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.MediaType +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(LedgerApiSmokeIT.TestSecurity::class) +class LedgerApiSmokeIT( + @Autowired private val rest: TestRestTemplate, +) { + @TestConfiguration + class TestSecurity { + @Bean + fun jwtDecoder(): JwtDecoder = + JwtDecoder { token -> + Jwt + .withTokenValue(token) + .header("alg", "none") + .subject("smoke-user") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(EXPIRY_SECONDS)) + .build() + } + } + + @Test + fun `should serve the openapi document with the ledger operations and license`() { + val response = rest.getForEntity("/v3/api-docs", String::class.java) + + response.statusCode.value() shouldBe 200 + val body = response.body ?: "" + body shouldContain "/v1/accounts" + body shouldContain "/v1/transactions" + body shouldContain "BUSL-1.1" + } + + @Test + fun `should replay an identical response for a repeated create with the same key and body`() { + val headers = + HttpHeaders().apply { + contentType = MediaType.APPLICATION_JSON + setBearerAuth("smoke-token") + set(IdempotencyAttributes.HEADER, "s".repeat(40)) + } + val payload = """{"name":"Smoke wallet","type":"USER_WALLET","currency":"EUR"}""" + val request = HttpEntity(payload, headers) + + val first = rest.postForEntity("/v1/accounts", request, String::class.java) + val second = rest.postForEntity("/v1/accounts", request, String::class.java) + + first.statusCode.value() shouldBe 201 + second.statusCode.value() shouldBe 201 + second.body shouldBe first.body + } + + @Test + fun `should reject an unauthenticated request with 401`() { + val response = rest.getForEntity("/v1/accounts/acc_0000000000000000000000000", String::class.java) + response.statusCode.value() shouldBe 401 + } + + companion object { + private const val EXPIRY_SECONDS = 300L + + @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" } + } + } +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/api/AccountController.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/api/AccountController.kt new file mode 100644 index 0000000..ee91307 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/api/AccountController.kt @@ -0,0 +1,84 @@ +// 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.core.AccountId +import com.fincore.core.IdempotencyKey +import com.fincore.ledger.api.dto.request.CreateAccountRequest +import com.fincore.ledger.api.dto.response.AccountResponse +import com.fincore.ledger.api.dto.response.BalanceResponse +import com.fincore.ledger.api.idempotency.IdempotencyAttributes +import com.fincore.ledger.api.mapper.LedgerApiMapper +import com.fincore.ledger.application.AccountService +import com.fincore.ledger.application.BalanceService +import com.fincore.ledger.application.IdempotencyService +import com.fincore.ledger.application.IdempotentResult +import com.fincore.ledger.application.StoredResponse +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestAttribute +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.net.URI + +@RestController +@RequestMapping("/v1/accounts") +class AccountController( + private val accountService: AccountService, + private val balanceService: BalanceService, + private val idempotencyService: IdempotencyService, + private val mapper: LedgerApiMapper, + private val objectMapper: ObjectMapper, +) { + @PostMapping + fun create( + @Valid @RequestBody request: CreateAccountRequest, + @AuthenticationPrincipal jwt: Jwt, + @RequestAttribute(IdempotencyAttributes.KEY) key: String, + @RequestAttribute(IdempotencyAttributes.BODY) rawBody: String, + ): ResponseEntity { + var location: URI? = null + val result = + idempotencyService.execute(IdempotencyKey.of(key), rawBody) { + val response = mapper.toResponse(accountService.create(mapper.toCommand(request, jwt.subject))) + location = URI.create("/v1/accounts/${response.id}") + StoredResponse(HttpStatus.CREATED.value(), objectMapper.writeValueAsString(response)) + } + return respond(result, location) + } + + @GetMapping("/{id}") + fun get( + @PathVariable id: String, + ): AccountResponse = mapper.toResponse(accountService.get(AccountId.fromString(id))) + + @GetMapping("/{id}/balance") + fun balance( + @PathVariable id: String, + ): BalanceResponse { + val accountId = AccountId.fromString(id) + val account = accountService.get(accountId) + return mapper.toResponse(balanceService.current(accountId, account.currency)) + } + + private fun respond( + result: IdempotentResult, + location: URI?, + ): ResponseEntity { + val status = requireNotNull(result.statusCode) { "idempotent result missing status" } + val body = requireNotNull(result.responseBody) { "idempotent result missing body" } + val builder = ResponseEntity.status(status).contentType(MediaType.APPLICATION_JSON) + if (!result.replayed) location?.let { builder.location(it) } + return builder.body(body) + } +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/api/TransactionController.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/api/TransactionController.kt new file mode 100644 index 0000000..2e32567 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/api/TransactionController.kt @@ -0,0 +1,69 @@ +// 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.core.IdempotencyKey +import com.fincore.ledger.api.dto.request.PostTransactionRequest +import com.fincore.ledger.api.idempotency.IdempotencyAttributes +import com.fincore.ledger.api.mapper.LedgerApiMapper +import com.fincore.ledger.application.IdempotencyService +import com.fincore.ledger.application.IdempotentResult +import com.fincore.ledger.application.StoredResponse +import com.fincore.ledger.application.TransactionService +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestAttribute +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.net.URI + +@RestController +@RequestMapping("/v1/transactions") +class TransactionController( + private val transactionService: TransactionService, + private val idempotencyService: IdempotencyService, + private val mapper: LedgerApiMapper, + private val objectMapper: ObjectMapper, +) { + @PostMapping + fun post( + @Valid @RequestBody request: PostTransactionRequest, + @AuthenticationPrincipal jwt: Jwt, + @RequestHeader(value = CORRELATION_HEADER, required = false) correlationId: String?, + @RequestAttribute(IdempotencyAttributes.KEY) key: String, + @RequestAttribute(IdempotencyAttributes.BODY) rawBody: String, + ): ResponseEntity { + var location: URI? = null + val result = + idempotencyService.execute(IdempotencyKey.of(key), rawBody) { + val response = mapper.toResponse(transactionService.post(mapper.toCommand(request, jwt.subject, correlationId))) + location = URI.create("/v1/transactions/${response.id}") + StoredResponse(HttpStatus.CREATED.value(), objectMapper.writeValueAsString(response)) + } + return respond(result, location) + } + + private fun respond( + result: IdempotentResult, + location: URI?, + ): ResponseEntity { + val status = requireNotNull(result.statusCode) { "idempotent result missing status" } + val body = requireNotNull(result.responseBody) { "idempotent result missing body" } + val builder = ResponseEntity.status(status).contentType(MediaType.APPLICATION_JSON) + if (!result.replayed) location?.let { builder.location(it) } + return builder.body(body) + } + + private companion object { + const val CORRELATION_HEADER = "X-Correlation-Id" + } +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/request/CreateAccountRequest.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/request/CreateAccountRequest.kt new file mode 100644 index 0000000..c8dc488 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/request/CreateAccountRequest.kt @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.api.dto.request + +import com.fincore.ledger.domain.enum.AccountType +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size + +data class CreateAccountRequest( + @field:NotBlank + @field:Size(min = 1, max = 255) + val name: String, + @field:NotNull + val type: AccountType, + @field:NotBlank + @field:Pattern(regexp = "^[A-Z]{3}$") + val currency: String, +) diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/request/PostTransactionRequest.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/request/PostTransactionRequest.kt new file mode 100644 index 0000000..877421a --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/request/PostTransactionRequest.kt @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.api.dto.request + +import com.fincore.ledger.domain.enum.EntryDirection +import jakarta.validation.Valid +import jakarta.validation.constraints.Digits +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import java.math.BigDecimal + +data class PostTransactionRequest( + @field:NotBlank + @field:Size(max = 255) + val reference: String, + @field:Size(max = 2048) + val description: String?, + @field:NotBlank + @field:Pattern(regexp = "^[A-Z]{3}$") + val currency: String, + @field:Size(min = 2, max = 1000) + @field:Valid + val entries: List, +) + +data class EntryLineRequest( + @field:NotBlank + val accountId: String, + @field:NotNull + val direction: EntryDirection, + @field:NotNull + @field:Digits(integer = 20, fraction = 18) + val amount: BigDecimal, +) diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/response/AccountResponse.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/response/AccountResponse.kt new file mode 100644 index 0000000..bab6258 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/response/AccountResponse.kt @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.api.dto.response + +import com.fincore.ledger.domain.enum.AccountStatus +import com.fincore.ledger.domain.enum.AccountType + +data class AccountResponse( + val id: String, + val name: String, + val type: AccountType, + val currency: String, + val status: AccountStatus, +) diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/response/BalanceResponse.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/response/BalanceResponse.kt new file mode 100644 index 0000000..c2faa0b --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/response/BalanceResponse.kt @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.api.dto.response + +import java.math.BigDecimal +import java.time.Instant + +data class BalanceResponse( + val accountId: String, + val currency: String, + val amount: BigDecimal, + val lastPostedAt: Instant?, +) diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/response/TransactionResponse.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/response/TransactionResponse.kt new file mode 100644 index 0000000..25ddc11 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/response/TransactionResponse.kt @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.api.dto.response + +import java.time.Instant + +data class TransactionResponse( + val id: String, + val reference: String, + val postedAt: Instant, +) diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/api/idempotency/CachedBodyHttpServletRequest.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/api/idempotency/CachedBodyHttpServletRequest.kt new file mode 100644 index 0000000..69866e5 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/api/idempotency/CachedBodyHttpServletRequest.kt @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.api.idempotency + +import jakarta.servlet.ReadListener +import jakarta.servlet.ServletInputStream +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletRequestWrapper +import java.io.BufferedReader +import java.io.ByteArrayInputStream +import java.io.InputStreamReader + +class CachedBodyHttpServletRequest( + request: HttpServletRequest, +) : HttpServletRequestWrapper(request) { + private val cachedBody: ByteArray = request.inputStream.readBytes() + + fun body(): ByteArray = cachedBody + + override fun getInputStream(): ServletInputStream = CachedBodyServletInputStream(cachedBody) + + override fun getReader(): BufferedReader = + BufferedReader(InputStreamReader(ByteArrayInputStream(cachedBody), characterEncoding ?: Charsets.UTF_8.name())) +} + +private class CachedBodyServletInputStream( + body: ByteArray, +) : ServletInputStream() { + private val buffer = ByteArrayInputStream(body) + + override fun read(): Int = buffer.read() + + override fun isFinished(): Boolean = buffer.available() == 0 + + override fun isReady(): Boolean = true + + override fun setReadListener(listener: ReadListener?): Unit = throw UnsupportedOperationException("async read unsupported") +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/api/idempotency/IdempotencyAttributes.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/api/idempotency/IdempotencyAttributes.kt new file mode 100644 index 0000000..90762c4 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/api/idempotency/IdempotencyAttributes.kt @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.api.idempotency + +object IdempotencyAttributes { + const val HEADER = "Idempotency-Key" + const val KEY = "fincore.idempotency.key" + const val BODY = "fincore.idempotency.body" +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/api/idempotency/IdempotencyFilter.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/api/idempotency/IdempotencyFilter.kt new file mode 100644 index 0000000..c35b75c --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/api/idempotency/IdempotencyFilter.kt @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.api.idempotency + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fincore.core.IdempotencyKey +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ProblemDetail +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import java.net.URI + +@Component +class IdempotencyFilter( + private val objectMapper: ObjectMapper, +) : OncePerRequestFilter() { + override fun shouldNotFilter(request: HttpServletRequest): Boolean = + request.method != HttpMethod.POST.name() || request.requestURI !in GUARDED_PATHS + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + val header = request.getHeader(IdempotencyAttributes.HEADER) + if (header.isNullOrBlank()) { + writeBadRequest(request, response, "Idempotency-Key header is required") + return + } + try { + IdempotencyKey.of(header) + } catch (ex: IllegalArgumentException) { + writeBadRequest(request, response, ex.message) + return + } + val cached = CachedBodyHttpServletRequest(request) + cached.setAttribute(IdempotencyAttributes.KEY, header) + cached.setAttribute(IdempotencyAttributes.BODY, String(cached.body(), Charsets.UTF_8)) + filterChain.doFilter(cached, response) + } + + private fun writeBadRequest( + request: HttpServletRequest, + response: HttpServletResponse, + detail: String?, + ) { + val problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, detail ?: "invalid request") + problem.title = "invalid request" + problem.type = URI.create("urn:fincore:ledger:invalid-request") + problem.instance = URI.create(request.requestURI) + response.status = HttpStatus.BAD_REQUEST.value() + response.contentType = MediaType.APPLICATION_PROBLEM_JSON_VALUE + objectMapper.writeValue(response.writer, problem) + } + + private companion object { + val GUARDED_PATHS = setOf("/v1/accounts", "/v1/transactions") + } +} 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 new file mode 100644 index 0000000..1cd9785 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/api/mapper/LedgerApiMapper.kt @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.api.mapper + +import com.fincore.core.AccountId +import com.fincore.core.Currency +import com.fincore.ledger.api.dto.request.CreateAccountRequest +import com.fincore.ledger.api.dto.request.PostTransactionRequest +import com.fincore.ledger.api.dto.response.AccountResponse +import com.fincore.ledger.api.dto.response.BalanceResponse +import com.fincore.ledger.api.dto.response.TransactionResponse +import com.fincore.ledger.application.AccountBalance +import com.fincore.ledger.application.CreateAccountCommand +import com.fincore.ledger.application.EntryLine +import com.fincore.ledger.application.PostTransactionCommand +import com.fincore.ledger.application.PostedTransaction +import com.fincore.ledger.domain.Account +import org.springframework.stereotype.Component + +// Hand-written, not MapStruct: the command/domain side uses Kotlin value classes (AccountId, Currency, +// Money) whose mangled getters and missing accessible constructors block MapStruct (issue #33 deferral). +@Component +class LedgerApiMapper { + fun toCommand( + request: CreateAccountRequest, + actor: String, + ): CreateAccountCommand = + CreateAccountCommand( + name = request.name, + type = request.type, + currency = Currency.of(request.currency), + actor = actor, + ) + + fun toResponse(account: Account): AccountResponse = + AccountResponse( + id = account.id.toString(), + name = account.name, + type = account.type, + currency = account.currency.code, + status = account.status, + ) + + fun toResponse(balance: AccountBalance): BalanceResponse = + BalanceResponse( + accountId = balance.accountId.toString(), + currency = balance.amount.currency.code, + amount = balance.amount.amount, + lastPostedAt = balance.lastPostedAt, + ) + + fun toCommand( + request: PostTransactionRequest, + actor: String, + correlationId: String?, + ): PostTransactionCommand = + PostTransactionCommand( + reference = request.reference, + description = request.description, + currency = Currency.of(request.currency), + entries = + request.entries.map { line -> + EntryLine( + accountId = AccountId.fromString(line.accountId), + direction = line.direction, + amount = line.amount, + ) + }, + actor = actor, + correlationId = correlationId, + ) + + fun toResponse(posted: PostedTransaction): TransactionResponse = + TransactionResponse( + id = posted.id.toString(), + reference = posted.reference, + postedAt = posted.postedAt, + ) +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/config/OpenApiConfig.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/config/OpenApiConfig.kt new file mode 100644 index 0000000..1d2faf9 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/config/OpenApiConfig.kt @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.config + +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.info.Info +import io.swagger.v3.oas.models.info.License +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class OpenApiConfig { + @Bean + fun ledgerOpenApi(): OpenAPI = + OpenAPI().info( + Info() + .title("FinCore Ledger API") + .version("0.1.0") + .license( + License() + .name("BUSL-1.1") + .url("https://github.com/tiana-code/fincore-engine/blob/main/LICENSE"), + ), + ) +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/config/SecurityConfig.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/config/SecurityConfig.kt new file mode 100644 index 0000000..73ead5b --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/config/SecurityConfig.kt @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.web.SecurityFilterChain + +@Configuration +@EnableWebSecurity +class SecurityConfig { + @Bean + fun filterChain(http: HttpSecurity): SecurityFilterChain = + http + .csrf { it.disable() } + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .authorizeHttpRequests { + it + .requestMatchers(*PUBLIC_PATHS) + .permitAll() + .anyRequest() + .authenticated() + }.oauth2ResourceServer { it.jwt {} } + .build() + + private companion object { + val PUBLIC_PATHS = + arrayOf( + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/actuator/health", + "/actuator/health/**", + ) + } +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/exception/GlobalExceptionHandler.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/exception/GlobalExceptionHandler.kt new file mode 100644 index 0000000..2b58ece --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/exception/GlobalExceptionHandler.kt @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.exception + +import com.fincore.ledger.domain.exception.AccountNotFoundException +import com.fincore.ledger.domain.exception.ConcurrencyConflictException +import com.fincore.ledger.domain.exception.CurrencyConsistencyViolationException +import com.fincore.ledger.domain.exception.DomainException +import com.fincore.ledger.domain.exception.DoubleEntryViolationException +import com.fincore.ledger.domain.exception.DuplicateTransactionException +import com.fincore.ledger.domain.exception.IdempotencyConflictException +import jakarta.servlet.http.HttpServletRequest +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.ProblemDetail +import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import java.net.URI + +@RestControllerAdvice +class GlobalExceptionHandler { + @ExceptionHandler(AccountNotFoundException::class) + fun handleAccountNotFound( + ex: AccountNotFoundException, + request: HttpServletRequest, + ): ProblemDetail = problem(HttpStatus.NOT_FOUND, "account not found", ex.message, request) + + @ExceptionHandler(DuplicateTransactionException::class) + fun handleDuplicateTransaction( + ex: DuplicateTransactionException, + request: HttpServletRequest, + ): ProblemDetail = problem(HttpStatus.CONFLICT, "duplicate transaction reference", ex.message, request) + + @ExceptionHandler(IdempotencyConflictException::class) + fun handleIdempotencyConflict( + ex: IdempotencyConflictException, + request: HttpServletRequest, + ): ProblemDetail = problem(HttpStatus.CONFLICT, "idempotency key conflict", ex.message, request) + + @ExceptionHandler(CurrencyConsistencyViolationException::class) + fun handleCurrencyConsistency( + ex: CurrencyConsistencyViolationException, + request: HttpServletRequest, + ): ProblemDetail = problem(HttpStatus.UNPROCESSABLE_ENTITY, "currency consistency violation", ex.message, request) + + @ExceptionHandler(DoubleEntryViolationException::class) + fun handleDoubleEntry( + ex: DoubleEntryViolationException, + request: HttpServletRequest, + ): ProblemDetail = problem(HttpStatus.UNPROCESSABLE_ENTITY, "double-entry violation", ex.message, request) + + @ExceptionHandler(DomainException::class) + fun handleDomain( + ex: DomainException, + request: HttpServletRequest, + ): ProblemDetail = problem(HttpStatus.UNPROCESSABLE_ENTITY, "domain rule violation", ex.message, request) + + @ExceptionHandler(ConcurrencyConflictException::class) + fun handleConcurrencyConflict( + ex: ConcurrencyConflictException, + request: HttpServletRequest, + ): ResponseEntity { + val body = problem(HttpStatus.SERVICE_UNAVAILABLE, "concurrency conflict, retry", ex.message, request) + return ResponseEntity + .status(HttpStatus.SERVICE_UNAVAILABLE) + .header(HttpHeaders.RETRY_AFTER, RETRY_AFTER_SECONDS) + .body(body) + } + + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidation( + ex: MethodArgumentNotValidException, + request: HttpServletRequest, + ): ProblemDetail { + val detail = problem(HttpStatus.BAD_REQUEST, "invalid request", "Request validation failed", request) + detail.setProperty( + "errors", + ex.bindingResult.fieldErrors.map { mapOf("field" to it.field, "message" to (it.defaultMessage ?: "invalid")) }, + ) + return detail + } + + @ExceptionHandler(HttpMessageNotReadableException::class) + fun handleUnreadable(request: HttpServletRequest): ProblemDetail = + problem(HttpStatus.BAD_REQUEST, "invalid request", "Request body is missing or malformed", request) + + @ExceptionHandler(IllegalArgumentException::class) + fun handleIllegalArgument( + ex: IllegalArgumentException, + request: HttpServletRequest, + ): ProblemDetail = problem(HttpStatus.BAD_REQUEST, "invalid request", ex.message, request) + + @ExceptionHandler(Exception::class) + fun handleUnexpected(request: HttpServletRequest): ProblemDetail = + problem(HttpStatus.INTERNAL_SERVER_ERROR, "internal error", "An unexpected error occurred", request) + + private fun problem( + status: HttpStatus, + title: String, + detail: String?, + request: HttpServletRequest, + ): ProblemDetail = + ProblemDetail.forStatusAndDetail(status, detail ?: title).apply { + this.title = title + this.type = URI.create(TYPE_PREFIX + title.replace(NON_SLUG, "-")) + this.instance = URI.create(request.requestURI) + } + + private companion object { + const val TYPE_PREFIX = "urn:fincore:ledger:" + const val RETRY_AFTER_SECONDS = "1" + val NON_SLUG = Regex("[^a-z0-9]+") + } +} diff --git a/services/ledger/src/main/resources/application.yml b/services/ledger/src/main/resources/application.yml new file mode 100644 index 0000000..10bf2ad --- /dev/null +++ b/services/ledger/src/main/resources/application.yml @@ -0,0 +1,13 @@ +spring: + application: + name: ledger-service +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html +management: + endpoints: + web: + exposure: + include: health,info,prometheus diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/api/AccountControllerTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/api/AccountControllerTest.kt new file mode 100644 index 0000000..e10359c --- /dev/null +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/api/AccountControllerTest.kt @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.api + +import com.fincore.core.AccountId +import com.fincore.core.Currency +import com.fincore.core.Money +import com.fincore.ledger.api.idempotency.IdempotencyAttributes +import com.fincore.ledger.api.idempotency.IdempotencyFilter +import com.fincore.ledger.api.mapper.LedgerApiMapper +import com.fincore.ledger.application.AccountBalance +import com.fincore.ledger.application.AccountService +import com.fincore.ledger.application.BalanceService +import com.fincore.ledger.application.CreateAccountCommand +import com.fincore.ledger.application.IdempotentResult +import com.fincore.ledger.config.SecurityConfig +import com.fincore.ledger.domain.Account +import com.fincore.ledger.domain.enum.AccountType +import com.fincore.ledger.domain.exception.AccountNotFoundException +import com.fincore.ledger.domain.exception.IdempotencyConflictException +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.hamcrest.Matchers.containsString +import org.hamcrest.Matchers.matchesPattern +import org.hamcrest.Matchers.not +import org.hamcrest.Matchers.startsWith +import org.junit.jupiter.api.BeforeEach +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.http.MediaType +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.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +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 + +@WebMvcTest(AccountController::class) +@Import(SecurityConfig::class, LedgerApiMapper::class, IdempotencyFilter::class, AccountControllerTest.Mocks::class) +class AccountControllerTest( + @Autowired private val mockMvc: MockMvc, + @Autowired private val accountService: AccountService, + @Autowired private val balanceService: BalanceService, + @Autowired private val idempotencyService: FakeIdempotencyService, +) { + private val key = "k".repeat(40) + + @TestConfiguration + class Mocks { + @Bean fun accountService(): AccountService = mockk() + + @Bean fun balanceService(): BalanceService = mockk() + + @Bean fun idempotencyService(): FakeIdempotencyService = FakeIdempotencyService() + + @Bean fun jwtDecoder(): JwtDecoder = mockk() + } + + @BeforeEach + fun resetMocks() { + idempotencyService.reset() + clearMocks(accountService, balanceService) + } + + @Test + fun `should create an account and return 201 with prefixed id and location`() { + val commandSlot = slot() + every { accountService.create(capture(commandSlot)) } returns + Account(AccountId.generate(), "Operating cash", AccountType.USER_WALLET, Currency.EUR) + + mockMvc + .perform( + post("/v1/accounts") + .with(jwt().jwt { it.subject("user-123") }) + .header(IdempotencyAttributes.HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"name":"Operating cash","type":"USER_WALLET","currency":"EUR"}"""), + ).andExpect(status().isCreated) + .andExpect(jsonPath("$.id").value(matchesPattern("^acc_[0-9A-HJKMNP-TV-Z]{26}$"))) + .andExpect(jsonPath("$.status").value("ACTIVE")) + .andExpect(jsonPath("$.currency").value("EUR")) + .andExpect(header().string("Location", startsWith("/v1/accounts/acc_"))) + + commandSlot.captured.actor shouldBe "user-123" + } + + @Test + fun `should return 404 when account is not found`() { + val id = AccountId.generate() + every { accountService.get(any()) } throws AccountNotFoundException(id) + + mockMvc + .perform(get("/v1/accounts/$id").with(jwt())) + .andExpect(status().isNotFound) + .andExpect(jsonPath("$.status").value(404)) + .andExpect(jsonPath("$.detail", not(containsString("Exception")))) + } + + @Test + fun `should return 400 for a malformed account id without calling the service`() { + mockMvc + .perform(get("/v1/accounts/acc_zzz").with(jwt())) + .andExpect(status().isBadRequest) + + verify(exactly = 0) { accountService.get(any()) } + } + + @Test + fun `should return the current balance in the account currency`() { + val id = AccountId.generate() + every { accountService.get(id) } returns Account(id, "Wallet", AccountType.USER_WALLET, Currency.EUR) + every { balanceService.current(id, Currency.EUR) } returns + AccountBalance(id, Money.of(BigDecimal("100.00"), Currency.EUR), Instant.parse("2026-06-13T10:00:00Z")) + + mockMvc + .perform(get("/v1/accounts/$id/balance").with(jwt())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.accountId").value(id.toString())) + .andExpect(jsonPath("$.currency").value("EUR")) + .andExpect(jsonPath("$.amount").value(100.00)) + } + + @Test + fun `should replay the stored response without re-invoking the service`() { + idempotencyService.handler = { _, _, _ -> IdempotentResult(201, """{"id":"acc_replayed"}""", replayed = true) } + + mockMvc + .perform( + post("/v1/accounts") + .with(jwt().jwt { it.subject("user-123") }) + .header(IdempotencyAttributes.HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"name":"Operating cash","type":"USER_WALLET","currency":"EUR"}"""), + ).andExpect(status().isCreated) + .andExpect(header().doesNotExist("Location")) + + verify(exactly = 0) { accountService.create(any()) } + } + + @Test + fun `should return 409 on idempotency key conflict`() { + idempotencyService.handler = { _, _, _ -> throw IdempotencyConflictException() } + + mockMvc + .perform( + post("/v1/accounts") + .with(jwt().jwt { it.subject("user-123") }) + .header(IdempotencyAttributes.HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"name":"Operating cash","type":"USER_WALLET","currency":"EUR"}"""), + ).andExpect(status().isConflict) + } + + @Test + fun `should return 400 when the idempotency key header is missing`() { + mockMvc + .perform( + post("/v1/accounts") + .with(jwt().jwt { it.subject("user-123") }) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"name":"Operating cash","type":"USER_WALLET","currency":"EUR"}"""), + ).andExpect(status().isBadRequest) + + verify(exactly = 0) { accountService.create(any()) } + } + + @Test + fun `should reject an unauthenticated request with 401`() { + mockMvc + .perform(get("/v1/accounts/${AccountId.generate()}")) + .andExpect(status().isUnauthorized) + } + + @Test + fun `should return 400 for an invalid currency code`() { + mockMvc + .perform( + post("/v1/accounts") + .with(jwt().jwt { it.subject("user-123") }) + .header(IdempotencyAttributes.HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"name":"Operating cash","type":"USER_WALLET","currency":"euro"}"""), + ).andExpect(status().isBadRequest) + } +} diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/api/FakeIdempotencyService.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/api/FakeIdempotencyService.kt new file mode 100644 index 0000000..0c29d40 --- /dev/null +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/api/FakeIdempotencyService.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.core.IdempotencyKey +import com.fincore.ledger.application.IdempotencyService +import com.fincore.ledger.application.IdempotentResult +import com.fincore.ledger.application.StoredResponse + +// Hand fake, not MockK: MockK cannot build a call signature for execute() because IdempotencyKey is a +// value class with init validation (its constructor rejects MockK's generated dummy string). +class FakeIdempotencyService : IdempotencyService { + var handler: (IdempotencyKey, String, () -> StoredResponse) -> IdempotentResult = RUN_ACTION + + override fun execute( + key: IdempotencyKey, + requestBody: String, + action: () -> StoredResponse, + ): IdempotentResult = handler(key, requestBody, action) + + fun reset() { + handler = RUN_ACTION + } + + companion object { + val RUN_ACTION: (IdempotencyKey, String, () -> StoredResponse) -> IdempotentResult = { _, _, action -> + val response = action() + IdempotentResult(response.statusCode, response.responseBody, replayed = false) + } + } +} diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/api/TransactionControllerTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/api/TransactionControllerTest.kt new file mode 100644 index 0000000..2dabb57 --- /dev/null +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/api/TransactionControllerTest.kt @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.api + +import com.fincore.core.AccountId +import com.fincore.core.TransactionId +import com.fincore.ledger.api.idempotency.IdempotencyAttributes +import com.fincore.ledger.api.idempotency.IdempotencyFilter +import com.fincore.ledger.api.mapper.LedgerApiMapper +import com.fincore.ledger.application.PostTransactionCommand +import com.fincore.ledger.application.PostedTransaction +import com.fincore.ledger.application.TransactionService +import com.fincore.ledger.config.SecurityConfig +import com.fincore.ledger.domain.enum.EntryDirection +import com.fincore.ledger.domain.exception.AccountNotFoundException +import com.fincore.ledger.domain.exception.ConcurrencyConflictException +import com.fincore.ledger.domain.exception.CurrencyConsistencyViolationException +import com.fincore.ledger.domain.exception.DoubleEntryViolationException +import com.fincore.ledger.domain.exception.DuplicateTransactionException +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import org.junit.jupiter.api.BeforeEach +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.http.MediaType +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.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.math.BigDecimal +import java.time.Instant + +@WebMvcTest(TransactionController::class) +@Import(SecurityConfig::class, LedgerApiMapper::class, IdempotencyFilter::class, TransactionControllerTest.Mocks::class) +class TransactionControllerTest( + @Autowired private val mockMvc: MockMvc, + @Autowired private val transactionService: TransactionService, + @Autowired private val idempotencyService: FakeIdempotencyService, +) { + private val key = "t".repeat(40) + private val accountA = AccountId.generate() + private val accountB = AccountId.generate() + + @TestConfiguration + class Mocks { + @Bean fun transactionService(): TransactionService = mockk() + + @Bean fun idempotencyService(): FakeIdempotencyService = FakeIdempotencyService() + + @Bean fun jwtDecoder(): JwtDecoder = mockk() + } + + @BeforeEach + fun resetMocks() { + idempotencyService.reset() + clearMocks(transactionService) + } + + private fun balancedBody(): String = + """ + {"reference":"tx-ref-1","currency":"EUR","entries":[ + {"accountId":"$accountA","direction":"DEBIT","amount":100.00}, + {"accountId":"$accountB","direction":"CREDIT","amount":-100.00}]} + """.trimIndent() + + private fun postBalanced(correlationId: String? = null) = + mockMvc.perform( + post("/v1/transactions") + .with(jwt().jwt { it.subject("user-123") }) + .header(IdempotencyAttributes.HEADER, key) + .apply { if (correlationId != null) header("X-Correlation-Id", correlationId) } + .contentType(MediaType.APPLICATION_JSON) + .content(balancedBody()), + ) + + @Test + fun `should post a balanced transaction and pass through signed amounts and actor`() { + val commandSlot = slot() + every { transactionService.post(capture(commandSlot)) } returns + PostedTransaction(TransactionId.generate(), "tx-ref-1", Instant.parse("2026-06-13T10:00:00Z")) + + postBalanced(correlationId = "corr-1") + .andExpect(status().isCreated) + .andExpect(header().string("Location", org.hamcrest.Matchers.startsWith("/v1/transactions/tx_"))) + + val command = commandSlot.captured + command.actor shouldBe "user-123" + command.correlationId shouldBe "corr-1" + command.currency.code shouldBe "EUR" + command.entries[0].accountId shouldBe accountA + command.entries[0].direction shouldBe EntryDirection.DEBIT + command.entries[0].amount.compareTo(BigDecimal("100.00")) shouldBe 0 + command.entries[1].amount.compareTo(BigDecimal("-100.00")) shouldBe 0 + } + + @Test + fun `should map a double-entry violation to 422`() { + every { transactionService.post(any()) } throws DoubleEntryViolationException("net != 0") + postBalanced().andExpect(status().isUnprocessableEntity) + } + + @Test + fun `should map a currency consistency violation to 422`() { + every { transactionService.post(any()) } throws CurrencyConsistencyViolationException("mismatch") + postBalanced().andExpect(status().isUnprocessableEntity) + } + + @Test + fun `should map a duplicate reference to 409`() { + every { transactionService.post(any()) } throws DuplicateTransactionException("tx-ref-1") + postBalanced().andExpect(status().isConflict) + } + + @Test + fun `should map a concurrency conflict to 503 with retry-after`() { + every { transactionService.post(any()) } throws ConcurrencyConflictException(RuntimeException("lock")) + postBalanced() + .andExpect(status().isServiceUnavailable) + .andExpect(header().string("Retry-After", "1")) + } + + @Test + fun `should map a missing account to 404`() { + every { transactionService.post(any()) } throws AccountNotFoundException(accountA) + postBalanced().andExpect(status().isNotFound) + } + + @Test + fun `should reject a single-entry transaction with 400 before calling the service`() { + mockMvc + .perform( + post("/v1/transactions") + .with(jwt().jwt { it.subject("user-123") }) + .header(IdempotencyAttributes.HEADER, key) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"reference":"r","currency":"EUR","entries":[{"accountId":"$accountA","direction":"DEBIT","amount":1}]}"""), + ).andExpect(status().isBadRequest) + } +} diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/api/idempotency/IdempotencyFilterTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/api/idempotency/IdempotencyFilterTest.kt new file mode 100644 index 0000000..a38e27c --- /dev/null +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/api/idempotency/IdempotencyFilterTest.kt @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.api.idempotency + +import com.fasterxml.jackson.databind.ObjectMapper +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import org.junit.jupiter.api.Test +import org.springframework.mock.web.MockFilterChain +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse + +class IdempotencyFilterTest { + private val filter = IdempotencyFilter(ObjectMapper()) + + private fun request( + method: String, + uri: String, + body: String = "{}", + ): MockHttpServletRequest = + MockHttpServletRequest(method, uri).apply { + setContent(body.toByteArray()) + contentType = "application/json" + } + + @Test + fun `should not filter GET requests`() { + val chain = MockFilterChain() + filter.doFilter(request("GET", "/v1/accounts/acc_x"), MockHttpServletResponse(), chain) + chain.request shouldNotBe null + } + + @Test + fun `should not filter unguarded POST paths`() { + val chain = MockFilterChain() + filter.doFilter(request("POST", "/v1/other"), MockHttpServletResponse(), chain) + chain.request shouldNotBe null + } + + @Test + fun `should reject a guarded POST without the idempotency key header`() { + val response = MockHttpServletResponse() + val chain = MockFilterChain() + filter.doFilter(request("POST", "/v1/accounts"), response, chain) + + response.status shouldBe 400 + response.contentType shouldContain "application/problem+json" + chain.request shouldBe null + } + + @Test + fun `should reject a syntactically invalid idempotency key`() { + val response = MockHttpServletResponse() + val chain = MockFilterChain() + val req = request("POST", "/v1/accounts").apply { addHeader(IdempotencyAttributes.HEADER, "short") } + filter.doFilter(req, response, chain) + + response.status shouldBe 400 + chain.request shouldBe null + } + + @Test + fun `should expose the key and buffered body to the chain on a valid request`() { + val body = """{"name":"Wallet"}""" + val req = + request("POST", "/v1/accounts", body).apply { + addHeader(IdempotencyAttributes.HEADER, "k".repeat(40)) + } + val chain = MockFilterChain() + filter.doFilter(req, MockHttpServletResponse(), chain) + + val passed = chain.request!! + passed.getAttribute(IdempotencyAttributes.KEY) shouldBe "k".repeat(40) + passed.getAttribute(IdempotencyAttributes.BODY) shouldBe body + passed.inputStream.readBytes().toString(Charsets.UTF_8) shouldBe body + } +} diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/api/mapper/LedgerApiMapperTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/api/mapper/LedgerApiMapperTest.kt new file mode 100644 index 0000000..7503412 --- /dev/null +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/api/mapper/LedgerApiMapperTest.kt @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.api.mapper + +import com.fincore.core.AccountId +import com.fincore.core.Currency +import com.fincore.core.Money +import com.fincore.core.TransactionId +import com.fincore.ledger.api.dto.request.CreateAccountRequest +import com.fincore.ledger.api.dto.request.EntryLineRequest +import com.fincore.ledger.api.dto.request.PostTransactionRequest +import com.fincore.ledger.application.AccountBalance +import com.fincore.ledger.application.PostedTransaction +import com.fincore.ledger.domain.Account +import com.fincore.ledger.domain.enum.AccountStatus +import com.fincore.ledger.domain.enum.AccountType +import com.fincore.ledger.domain.enum.EntryDirection +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldStartWith +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +class LedgerApiMapperTest { + private val mapper = LedgerApiMapper() + + @Test + fun `should map create request to command with parsed currency and injected actor`() { + val command = mapper.toCommand(CreateAccountRequest("Wallet", AccountType.USER_WALLET, "EUR"), "user-1") + + command.name shouldBe "Wallet" + command.type shouldBe AccountType.USER_WALLET + command.currency shouldBe Currency.EUR + command.actor shouldBe "user-1" + } + + @Test + fun `should serialize account id as prefixed ulid`() { + val account = Account(AccountId.generate(), "Wallet", AccountType.ASSET, Currency.USD, AccountStatus.FROZEN) + val response = mapper.toResponse(account) + + response.id shouldBe account.id.toString() + response.id shouldStartWith "acc_" + response.currency shouldBe "USD" + response.status shouldBe AccountStatus.FROZEN + } + + @Test + fun `should preserve full money precision in balance response`() { + val accountId = AccountId.generate() + val amount = BigDecimal("12345.678901234567890123") + val response = mapper.toResponse(AccountBalance(accountId, Money.of(amount, Currency.EUR), null)) + + response.accountId shouldBe accountId.toString() + response.currency shouldBe "EUR" + response.amount.compareTo(amount) shouldBe 0 + response.amount.scale() shouldBe 18 + } + + @Test + fun `should parse entry account ids and keep signed amounts when mapping a post command`() { + val a = AccountId.generate() + val b = AccountId.generate() + val request = + PostTransactionRequest( + reference = "ref-1", + description = null, + currency = "EUR", + entries = + listOf( + EntryLineRequest(a.toString(), EntryDirection.DEBIT, BigDecimal("100.00")), + EntryLineRequest(b.toString(), EntryDirection.CREDIT, BigDecimal("-100.00")), + ), + ) + + val command = mapper.toCommand(request, "user-1", "corr-1") + + command.currency shouldBe Currency.EUR + command.actor shouldBe "user-1" + command.correlationId shouldBe "corr-1" + command.entries[0].accountId shouldBe a + command.entries[0].amount.compareTo(BigDecimal("100.00")) shouldBe 0 + command.entries[1].accountId shouldBe b + command.entries[1].amount.compareTo(BigDecimal("-100.00")) shouldBe 0 + } + + @Test + fun `should serialize transaction id as prefixed ulid`() { + val posted = PostedTransaction(TransactionId.generate(), "ref-1", java.time.Instant.now()) + mapper.toResponse(posted).id shouldStartWith "tx_" + } +} From 14fa3ddade8583195973d9f718f6c032a3c69ce3 Mon Sep 17 00:00:00 2001 From: "@tanya_r" Date: Sat, 13 Jun 2026 21:32:29 -0300 Subject: [PATCH 2/2] test(ledger): compare idempotent replay as json in smoke it response_body is a jsonb column normalized by postgres, so replay is logically identical rather than byte-identical; compare parsed json trees. --- .../kotlin/com/fincore/ledger/api/LedgerApiSmokeIT.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/LedgerApiSmokeIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/LedgerApiSmokeIT.kt index b0cdebb..ade9251 100644 --- a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/LedgerApiSmokeIT.kt +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/LedgerApiSmokeIT.kt @@ -3,6 +3,7 @@ package com.fincore.ledger.api +import com.fasterxml.jackson.databind.ObjectMapper import com.fincore.ledger.api.idempotency.IdempotencyAttributes import com.fincore.test.containers.PostgresContainerExtension import io.kotest.matchers.shouldBe @@ -29,6 +30,7 @@ import java.time.Instant @Import(LedgerApiSmokeIT.TestSecurity::class) class LedgerApiSmokeIT( @Autowired private val rest: TestRestTemplate, + @Autowired private val objectMapper: ObjectMapper, ) { @TestConfiguration class TestSecurity { @@ -72,7 +74,9 @@ class LedgerApiSmokeIT( first.statusCode.value() shouldBe 201 second.statusCode.value() shouldBe 201 - second.body shouldBe first.body + // response_body is a JSONB column (normalized by Postgres), so the replay is logically identical + // rather than byte-identical; compare the parsed JSON trees. + objectMapper.readTree(second.body) shouldBe objectMapper.readTree(first.body) } @Test