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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions services/ledger/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// 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.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,
@Autowired private val objectMapper: ObjectMapper,
) {
@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
// 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
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" }
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String> {
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<String> {
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)
}
}
Original file line number Diff line number Diff line change
@@ -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<String> {
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<String> {
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"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.ledger.api.dto.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,
)
Original file line number Diff line number Diff line change
@@ -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<EntryLineRequest>,
)

data class EntryLineRequest(
@field:NotBlank
val accountId: String,
@field:NotNull
val direction: EntryDirection,
@field:NotNull
@field:Digits(integer = 20, fraction = 18)
val amount: BigDecimal,
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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?,
)
Original file line number Diff line number Diff line change
@@ -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,
)
Loading
Loading