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

Filter by extension

Filter by extension

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

package com.fincore.ledger.application

import com.fincore.core.Currency
import com.fincore.core.IdempotencyKey
import com.fincore.ledger.domain.enum.AccountStatus
import com.fincore.ledger.domain.enum.AccountType
import com.fincore.ledger.domain.exception.IdempotencyConflictException
import com.fincore.ledger.infrastructure.persistence.AccountBalanceEntity
import com.fincore.ledger.infrastructure.persistence.AccountBalanceKey
import com.fincore.ledger.infrastructure.persistence.AccountBalanceRepository
import com.fincore.ledger.infrastructure.persistence.AccountPersistenceAdapter
import com.fincore.test.containers.PostgresContainerExtension
import io.kotest.assertions.throwables.shouldThrow
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.context.annotation.Import
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import java.math.BigDecimal
import java.time.Instant

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ExtendWith(PostgresContainerExtension::class)
@Import(
AccountServiceImpl::class,
AccountPersistenceAdapter::class,
IdempotencyServiceImpl::class,
IdempotencyStore::class,
)
class AccountIdempotencyServiceIT(
@Autowired private val accountService: AccountService,
@Autowired private val idempotencyService: IdempotencyService,
@Autowired private val balanceRepository: AccountBalanceRepository,
) {
@Test
fun `should create and read back an account`() {
val created = accountService.create(CreateAccountCommand("Wallet", AccountType.USER_WALLET, Currency.USD, "auth0|op"))

val loaded = accountService.get(created.id)
loaded.name shouldBe "Wallet"
loaded.currency shouldBe Currency.USD
loaded.status shouldBe AccountStatus.ACTIVE
}

@Test
fun `should replay an idempotent action without re-running it`() {
val key = IdempotencyKey.of("k".repeat(40))
var runs = 0

val first =
idempotencyService.execute(key, "{\"a\":1}") {
runs++
StoredResponse(201, "{\"id\":\"x\"}")
}
val second =
idempotencyService.execute(key, "{\"a\":1}") {
runs++
StoredResponse(500, "should not run")
}

runs shouldBe 1
first.replayed shouldBe false
second.replayed shouldBe true
second.statusCode shouldBe 201
}

@Test
fun `should conflict when the same key sees a different request`() {
val key = IdempotencyKey.of("c".repeat(40))
idempotencyService.execute(key, "{\"a\":1}") { StoredResponse(201, "{}") }

shouldThrow<IdempotencyConflictException> {
idempotencyService.execute(key, "{\"a\":2}") { StoredResponse(201, "{}") }
}
}

@Test
fun `should refuse to close an account holding a non-zero balance`() {
val created = accountService.create(CreateAccountCommand("Wallet", AccountType.USER_WALLET, Currency.USD, "op"))
balanceRepository.saveAndFlush(
AccountBalanceEntity(AccountBalanceKey(created.id.value, "USD"), BigDecimal("10.50"), Instant.now(), 0),
)

shouldThrow<com.fincore.ledger.domain.exception.DomainException> {
accountService.changeStatus(created.id, AccountStatus.CLOSED, "op")
}
}

@Test
fun `should close an empty account`() {
val created = accountService.create(CreateAccountCommand("Wallet", AccountType.USER_WALLET, Currency.USD, "op"))

val closed = accountService.changeStatus(created.id, AccountStatus.CLOSED, "op")

closed.status shouldBe AccountStatus.CLOSED
}

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" }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.ledger.application

import com.fincore.core.AccountId
import com.fincore.ledger.domain.Account
import com.fincore.ledger.domain.enum.AccountStatus

interface AccountService {
fun create(command: CreateAccountCommand): Account

fun get(id: AccountId): Account

fun rename(
id: AccountId,
newName: String,
actor: String,
): Account

fun changeStatus(
id: AccountId,
target: AccountStatus,
actor: String,
): Account
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.ledger.application

import com.fincore.core.AccountId
import com.fincore.ledger.domain.Account
import com.fincore.ledger.domain.enum.AccountStatus
import com.fincore.ledger.domain.exception.AccountNotFoundException
import com.fincore.ledger.domain.exception.DomainException
import com.fincore.ledger.infrastructure.persistence.AccountBalanceRepository
import com.fincore.ledger.infrastructure.persistence.AccountEntity
import com.fincore.ledger.infrastructure.persistence.AccountPersistenceAdapter
import com.fincore.ledger.infrastructure.persistence.AccountRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Instant

@Service
class AccountServiceImpl(
private val accountRepository: AccountRepository,
private val balanceRepository: AccountBalanceRepository,
private val adapter: AccountPersistenceAdapter,
) : AccountService {
@Transactional
override fun create(command: CreateAccountCommand): Account {
val account = Account(AccountId.generate(), command.name, command.type, command.currency)
val entity = adapter.toNewEntity(account, command.actor, Instant.now())
accountRepository.saveAndFlush(entity)
return adapter.toDomain(entity)
}

@Transactional(readOnly = true)
override fun get(id: AccountId): Account = adapter.toDomain(load(id))

@Transactional
override fun rename(
id: AccountId,
newName: String,
actor: String,
): Account {
val entity = load(id)
val account = adapter.toDomain(entity)
account.rename(newName)
entity.name = account.name
entity.updatedBy = actor
return adapter.toDomain(accountRepository.saveAndFlush(entity))
}

@Transactional
override fun changeStatus(
id: AccountId,
target: AccountStatus,
actor: String,
): Account {
val entity = load(id)
if (target == AccountStatus.CLOSED) {
requireZeroBalance(id)
}
val account = adapter.toDomain(entity)
account.transitionStatus(target)
entity.status = account.status
entity.updatedBy = actor
return adapter.toDomain(accountRepository.saveAndFlush(entity))
}

private fun load(id: AccountId): AccountEntity = accountRepository.findById(id.value).orElseThrow { AccountNotFoundException(id) }

private fun requireZeroBalance(id: AccountId) {
val hasFunds = balanceRepository.findByKeyAccountId(id.value).any { it.balance.signum() != 0 }
if (hasFunds) {
throw DomainException("Cannot close account $id while it holds a non-zero balance")
}
}
}
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.application

import com.fincore.core.Currency
import com.fincore.ledger.domain.enum.AccountType

data class CreateAccountCommand(
val name: String,
val type: AccountType,
val currency: Currency,
val actor: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.ledger.application

import com.fincore.core.IdempotencyKey

data class StoredResponse(
val statusCode: Int,
val responseBody: String,
)

data class IdempotentResult(
val statusCode: Int?,
val responseBody: String?,
val replayed: Boolean,
)

interface IdempotencyService {
fun execute(
key: IdempotencyKey,
requestBody: String,
action: () -> StoredResponse,
): IdempotentResult
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.ledger.application

import com.fincore.core.IdempotencyKey
import org.springframework.stereotype.Service
import java.security.MessageDigest

@Service
class IdempotencyServiceImpl(
private val store: IdempotencyStore,
) : IdempotencyService {
@Suppress("SwallowedException") // race is recovered by retry in a fresh transaction, not an error
override fun execute(
key: IdempotencyKey,
requestBody: String,
action: () -> StoredResponse,
): IdempotentResult {
val keyHash = sha256Hex(key.value)
val requestHash = sha256Hex(requestBody)
return try {
store.runOrReplay(keyHash, requestHash, action)
} catch (race: IdempotencyRaceException) {
// The winner committed its response; a fresh transaction now replays it.
store.runOrReplay(keyHash, requestHash, action)
}
}

private fun sha256Hex(value: String): String =
MessageDigest
.getInstance("SHA-256")
.digest(value.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.ledger.application

import com.fincore.ledger.domain.exception.IdempotencyConflictException
import com.fincore.ledger.infrastructure.persistence.IdempotencyKeyEntity
import com.fincore.ledger.infrastructure.persistence.IdempotencyKeyRepository
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
import java.time.Duration
import java.time.Instant

// Raised when a concurrent caller has already claimed the key in this transaction window. The
// orchestrator retries in a fresh transaction, where the winner's committed response is replayed.
internal class IdempotencyRaceException(
cause: Throwable,
) : RuntimeException(cause)

@Component
class IdempotencyStore(
private val repository: IdempotencyKeyRepository,
) {
@Transactional
fun runOrReplay(
keyHash: String,
requestHash: String,
action: () -> StoredResponse,
): IdempotentResult {
val now = Instant.now()
val existing = repository.findById(keyHash).orElse(null)
if (existing != null && existing.expiresAt.isAfter(now)) {
if (existing.requestHash != requestHash) {
throw IdempotencyConflictException()
}
return IdempotentResult(existing.statusCode, existing.responseBody, replayed = true)
}
if (existing != null) {
repository.delete(existing)
repository.flush()
}
val reservation = IdempotencyKeyEntity(keyHash, requestHash, null, null, now, now.plus(TTL))
try {
repository.saveAndFlush(reservation)
} catch (duplicate: DataIntegrityViolationException) {
throw IdempotencyRaceException(duplicate)
}
val response = action()
reservation.statusCode = response.statusCode
reservation.responseBody = response.responseBody
repository.saveAndFlush(reservation)
return IdempotentResult(response.statusCode, response.responseBody, replayed = false)
}

companion object {
private val TTL = Duration.ofHours(24)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.ledger.domain.exception

import com.fincore.core.AccountId

class AccountNotFoundException(
id: AccountId,
) : DomainException("Account not found: $id")
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.ledger.domain.exception

class IdempotencyConflictException : DomainException("Idempotency key reused with a different request payload")
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@
package com.fincore.ledger.infrastructure.persistence

import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID

interface AccountBalanceRepository : JpaRepository<AccountBalanceEntity, AccountBalanceKey>
interface AccountBalanceRepository : JpaRepository<AccountBalanceEntity, AccountBalanceKey> {
fun findByKeyAccountId(accountId: UUID): List<AccountBalanceEntity>
}
Loading
Loading