diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AccountEntriesIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AccountEntriesIT.kt index be17658..3db378a 100644 --- a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AccountEntriesIT.kt +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AccountEntriesIT.kt @@ -10,6 +10,7 @@ import com.fincore.core.AccountId import com.fincore.core.Currency import com.fincore.ledger.domain.enum.AccountType import com.fincore.ledger.domain.enum.EntryDirection +import com.fincore.ledger.infrastructure.audit.AuditTrailWriterImpl import com.fincore.ledger.infrastructure.outbox.OutboxEventPublisherImpl import com.fincore.ledger.infrastructure.persistence.AccountPersistenceAdapter import com.fincore.ledger.infrastructure.persistence.TransactionPersistenceAdapter @@ -41,6 +42,7 @@ import java.time.Instant AccountPersistenceAdapter::class, AccountEntriesIT.JacksonConfig::class, OutboxEventPublisherImpl::class, + AuditTrailWriterImpl::class, ) class AccountEntriesIT( @Autowired private val entryQueryService: EntryQueryService, diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AccountIdempotencyServiceIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AccountIdempotencyServiceIT.kt index 142ebb4..ab1d9e4 100644 --- a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AccountIdempotencyServiceIT.kt +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AccountIdempotencyServiceIT.kt @@ -3,11 +3,15 @@ package com.fincore.ledger.application +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 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.audit.AuditTrailWriterImpl import com.fincore.ledger.infrastructure.persistence.AccountBalanceEntity import com.fincore.ledger.infrastructure.persistence.AccountBalanceKey import com.fincore.ledger.infrastructure.persistence.AccountBalanceRepository @@ -20,6 +24,8 @@ 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.context.TestConfiguration +import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Import import org.springframework.test.context.DynamicPropertyRegistry import org.springframework.test.context.DynamicPropertySource @@ -34,12 +40,20 @@ import java.time.Instant AccountPersistenceAdapter::class, IdempotencyServiceImpl::class, IdempotencyStore::class, + AuditTrailWriterImpl::class, + AccountIdempotencyServiceIT.JacksonConfig::class, ) class AccountIdempotencyServiceIT( @Autowired private val accountService: AccountService, @Autowired private val idempotencyService: IdempotencyService, @Autowired private val balanceRepository: AccountBalanceRepository, ) { + @TestConfiguration + class JacksonConfig { + @Bean + fun objectMapper(): ObjectMapper = jacksonObjectMapper().registerModule(JavaTimeModule()) + } + @Test fun `should create and read back an account`() { val created = accountService.create(CreateAccountCommand("Wallet", AccountType.USER_WALLET, Currency.USD, "auth0|op")) diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AuditRetryTopologyIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AuditRetryTopologyIT.kt new file mode 100644 index 0000000..780cad6 --- /dev/null +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AuditRetryTopologyIT.kt @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.application + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fincore.core.Currency +import com.fincore.core.IdempotencyKey +import com.fincore.ledger.domain.enum.AccountType +import com.fincore.ledger.domain.enum.AuditAction +import com.fincore.ledger.domain.enum.EntryDirection +import com.fincore.ledger.infrastructure.audit.AuditTrailWriterImpl +import com.fincore.ledger.infrastructure.outbox.OutboxEventPublisherImpl +import com.fincore.ledger.infrastructure.persistence.AccountPersistenceAdapter +import com.fincore.ledger.infrastructure.persistence.AuditEventRepository +import com.fincore.ledger.infrastructure.persistence.TransactionPersistenceAdapter +import com.fincore.ledger.infrastructure.persistence.TransactionRepository +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.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import java.math.BigDecimal + +/** + * Drives the real nesting idempotencyService.execute -> IdempotencyStore.runOrReplay (@Transactional) + * -> withOptimisticRetry -> TransactionPoster.post, and asserts that posting through that chain writes + * exactly one TRANSACTION_POST audit row together with the business write. The contended + * optimistic-retry-inside-the-idempotency-transaction path is a pre-existing concern tracked in a + * separate follow-up issue and is not exercised here. + */ +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ExtendWith(PostgresContainerExtension::class) +@Import( + AccountServiceImpl::class, + AccountPersistenceAdapter::class, + TransactionServiceImpl::class, + TransactionPoster::class, + TransactionPersistenceAdapter::class, + BalanceServiceImpl::class, + IdempotencyServiceImpl::class, + IdempotencyStore::class, + AuditTrailWriterImpl::class, + OutboxEventPublisherImpl::class, + AuditRetryTopologyIT.JacksonConfig::class, +) +class AuditRetryTopologyIT( + @Autowired private val accountService: AccountService, + @Autowired private val transactionService: TransactionService, + @Autowired private val idempotencyService: IdempotencyService, + @Autowired private val auditRepository: AuditEventRepository, + @Autowired private val transactionRepository: TransactionRepository, +) { + @TestConfiguration + class JacksonConfig { + @Bean + fun objectMapper(): ObjectMapper = jacksonObjectMapper().registerModule(JavaTimeModule()) + } + + private fun newAccount(): com.fincore.ledger.domain.Account = + accountService.create(CreateAccountCommand("Topology Account", AccountType.USER_WALLET, Currency.USD, ACTOR)) + + @Test + fun `should write exactly one TRANSACTION_POST audit row when post runs through the idempotency path`() { + val debit = newAccount() + val credit = newAccount() + val key = IdempotencyKey.of("retry-topo-happy-00000000000000000000000".take(40)) + val body = """{"reference":"ref-topo-happy","currency":"USD"}""" + var postedId = "" + + idempotencyService.execute(key, body) { hash -> + val posted = + transactionService.post( + PostTransactionCommand( + reference = "ref-topo-happy", + description = null, + currency = Currency.USD, + entries = + listOf( + EntryLine(debit.id, EntryDirection.DEBIT, BigDecimal("100.00")), + EntryLine(credit.id, EntryDirection.CREDIT, BigDecimal("-100.00")), + ), + actor = ACTOR, + correlationId = CORR_ID, + requestHash = hash, + ), + ) + postedId = posted.id.toString() + StoredResponse(201, """{"id":"${posted.id}"}""") + } + + auditRepository.findAll().filter { it.resourceId == postedId && it.action == AuditAction.TRANSACTION_POST.name }.size shouldBe 1 + transactionRepository.findAll().filter { it.reference == "ref-topo-happy" }.size shouldBe 1 + } + + companion object { + const val ACTOR = "auth0|topology-actor" + const val CORR_ID = "corr-retry-topo-001" + + @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/integrationTest/kotlin/com/fincore/ledger/application/AuditWritePathIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AuditWritePathIT.kt new file mode 100644 index 0000000..6a314a0 --- /dev/null +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AuditWritePathIT.kt @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.application + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fincore.core.AccountId +import com.fincore.core.Currency +import com.fincore.core.IdempotencyKey +import com.fincore.ledger.api.observability.CorrelationIdAttributes +import com.fincore.ledger.domain.enum.AccountStatus +import com.fincore.ledger.domain.enum.AccountType +import com.fincore.ledger.domain.enum.AuditAction +import com.fincore.ledger.domain.enum.AuditResourceType +import com.fincore.ledger.domain.enum.AuditResult +import com.fincore.ledger.domain.enum.EntryDirection +import com.fincore.ledger.infrastructure.audit.AuditTrailWriterImpl +import com.fincore.ledger.infrastructure.outbox.OutboxEventPublisherImpl +import com.fincore.ledger.infrastructure.persistence.AccountPersistenceAdapter +import com.fincore.ledger.infrastructure.persistence.AuditEventRepository +import com.fincore.ledger.infrastructure.persistence.TransactionPersistenceAdapter +import com.fincore.test.containers.PostgresContainerExtension +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldHaveLength +import io.kotest.matchers.string.shouldNotBeBlank +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.slf4j.MDC +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.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import java.math.BigDecimal +import java.security.MessageDigest + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ExtendWith(PostgresContainerExtension::class) +@Import( + AccountServiceImpl::class, + AccountPersistenceAdapter::class, + TransactionServiceImpl::class, + TransactionPoster::class, + TransactionPersistenceAdapter::class, + BalanceServiceImpl::class, + IdempotencyServiceImpl::class, + IdempotencyStore::class, + AuditTrailWriterImpl::class, + OutboxEventPublisherImpl::class, + AuditWritePathIT.JacksonConfig::class, +) +class AuditWritePathIT( + @Autowired private val accountService: AccountService, + @Autowired private val transactionService: TransactionService, + @Autowired private val idempotencyService: IdempotencyService, + @Autowired private val auditRepository: AuditEventRepository, + @Autowired private val objectMapper: ObjectMapper, +) { + @TestConfiguration + class JacksonConfig { + @Bean + fun objectMapper(): ObjectMapper = jacksonObjectMapper().registerModule(JavaTimeModule()) + } + + @AfterEach + fun cleanUp() { + MDC.clear() + } + + private fun rowsFor( + resourceId: String, + action: AuditAction, + ) = auditRepository.findAll().filter { it.resourceId == resourceId && it.action == action.name } + + private fun newAccount(): com.fincore.ledger.domain.Account = + accountService.create( + CreateAccountCommand("Test Account", AccountType.USER_WALLET, Currency.USD, ACTOR), + ) + + private fun postBalanced( + reference: String, + debit: AccountId, + credit: AccountId, + requestHash: String? = null, + ): PostedTransaction = + transactionService.post( + PostTransactionCommand( + reference = reference, + description = null, + currency = Currency.USD, + entries = + listOf( + EntryLine(debit, EntryDirection.DEBIT, BigDecimal("100.00")), + EntryLine(credit, EntryDirection.CREDIT, BigDecimal("-100.00")), + ), + actor = ACTOR, + correlationId = CORR_ID, + requestHash = requestHash, + ), + ) + + // AC-1: one ACCOUNT_CREATE row with correct fields and request_hash + @Test + fun `should write exactly one ACCOUNT_CREATE audit row with correct fields when account create succeeds`() { + MDC.put(CorrelationIdAttributes.MDC_KEY, CORR_ID) + val requestBody = """{"name":"Test Account","type":"USER_WALLET","currency":"USD"}""" + val expectedHash = sha256Hex(requestBody) + + val account = + accountService.create( + CreateAccountCommand( + name = "Test Account", + type = AccountType.USER_WALLET, + currency = Currency.USD, + actor = ACTOR, + requestHash = expectedHash, + ), + ) + + val rows = rowsFor(account.id.toString(), AuditAction.ACCOUNT_CREATE) + rows.size shouldBe 1 + val row = rows.first() + row.action shouldBe AuditAction.ACCOUNT_CREATE.name + row.resourceType shouldBe AuditResourceType.ACCOUNT.name + row.resourceId shouldBe account.id.toString() + row.result shouldBe AuditResult.SUCCESS + row.actorId shouldBe ACTOR + row.correlationId shouldBe CORR_ID + row.requestHash.shouldNotBeNull() shouldHaveLength 64 + row.requestHash shouldBe expectedHash + } + + // AC-2: ACCOUNT_STATUS_CHANGE row, request_hash NULL, payload has status + @Test + fun `should write exactly one ACCOUNT_STATUS_CHANGE row with status payload when changeStatus succeeds`() { + val account = newAccount() + + accountService.changeStatus(account.id, AccountStatus.FROZEN, ACTOR) + + val rows = rowsFor(account.id.toString(), AuditAction.ACCOUNT_STATUS_CHANGE) + rows.size shouldBe 1 + val row = rows.first() + row.action shouldBe AuditAction.ACCOUNT_STATUS_CHANGE.name + row.resourceId shouldBe account.id.toString() + row.result shouldBe AuditResult.SUCCESS + row.requestHash.shouldBeNull() + row.correlationId.shouldNotBeBlank() + val payloadJson = row.payload.shouldNotBeNull() + val tree = objectMapper.readTree(payloadJson) + tree.get("status").asText() shouldBe "FROZEN" + } + + // AC-3: ACCOUNT_RENAME row, request_hash NULL + @Test + fun `should write exactly one ACCOUNT_RENAME row when rename succeeds`() { + val account = newAccount() + + accountService.rename(account.id, "Renamed Account", ACTOR) + + val rows = rowsFor(account.id.toString(), AuditAction.ACCOUNT_RENAME) + rows.size shouldBe 1 + val row = rows.first() + row.action shouldBe AuditAction.ACCOUNT_RENAME.name + row.resourceId shouldBe account.id.toString() + row.result shouldBe AuditResult.SUCCESS + row.requestHash.shouldBeNull() + row.correlationId.shouldNotBeBlank() + } + + // AC-4: TRANSACTION_POST row with request_hash + @Test + fun `should write exactly one TRANSACTION_POST audit row with request_hash when post succeeds`() { + MDC.put(CorrelationIdAttributes.MDC_KEY, CORR_ID) + val debit = newAccount() + val credit = newAccount() + + val requestBody = """{"reference":"ref-audit-4","currency":"USD"}""" + val expectedHash = sha256Hex(requestBody) + val posted = postBalanced("ref-audit-4", debit.id, credit.id, requestHash = expectedHash) + + val rows = rowsFor(posted.id.toString(), AuditAction.TRANSACTION_POST) + rows.size shouldBe 1 + val row = rows.first() + row.action shouldBe AuditAction.TRANSACTION_POST.name + row.resourceType shouldBe AuditResourceType.TRANSACTION.name + row.resourceId shouldBe posted.id.toString() + row.result shouldBe AuditResult.SUCCESS + row.requestHash.shouldNotBeNull() shouldHaveLength 64 + row.requestHash shouldBe expectedHash + } + + // AC-5: TRANSACTION_REVERSE with reason, resource_id = original tx id, payload has reason + compensating id + @Test + fun `should write TRANSACTION_REVERSE row with reason and compensating id when reverse with reason succeeds`() { + val debit = newAccount() + val credit = newAccount() + val original = postBalanced("ref-audit-5", debit.id, credit.id) + + val requestBody = """{"reason":"duplicate posting"}""" + val expectedHash = sha256Hex(requestBody) + val compensating = + transactionService.reverse( + original.id, + ACTOR, + CORR_ID, + reason = "duplicate posting", + requestHash = expectedHash, + ) + + val rows = rowsFor(original.id.toString(), AuditAction.TRANSACTION_REVERSE) + rows.size shouldBe 1 + val row = rows.first() + row.action shouldBe AuditAction.TRANSACTION_REVERSE.name + row.resourceId shouldBe original.id.toString() + row.result shouldBe AuditResult.SUCCESS + val payloadJson = row.payload.shouldNotBeNull() + val tree = objectMapper.readTree(payloadJson) + tree.has("reason").shouldBeTrue() + tree.get("reason").asText() shouldBe "duplicate posting" + tree.has("compensatingTransactionId").shouldBeTrue() + tree.get("compensatingTransactionId").asText() shouldBe compensating.id.toString() + } + + // AC-6: TRANSACTION_REVERSE without reason, payload has no reason key + @Test + fun `should write TRANSACTION_REVERSE row with no reason key in payload when reverse has no reason`() { + val debit = newAccount() + val credit = newAccount() + val original = postBalanced("ref-audit-6", debit.id, credit.id) + + val compensating = + transactionService.reverse( + original.id, + ACTOR, + CORR_ID, + reason = null, + requestHash = null, + ) + + val rows = rowsFor(original.id.toString(), AuditAction.TRANSACTION_REVERSE) + rows.size shouldBe 1 + val row = rows.first() + row.action shouldBe AuditAction.TRANSACTION_REVERSE.name + row.resourceId shouldBe original.id.toString() + val payloadJson = row.payload.shouldNotBeNull() + val tree = objectMapper.readTree(payloadJson) + tree.has("reason").shouldBeFalse() + tree.has("compensatingTransactionId").shouldBeTrue() + tree.get("compensatingTransactionId").asText() shouldBe compensating.id.toString() + } + + // AC-9: idempotent replay writes no second audit row + @Test + fun `should write no second audit row when idempotent replay is executed`() { + val key = IdempotencyKey.of("audit-idem-key-0000000000000000000000") + val requestBody = """{"name":"Idempotent Account","type":"USER_WALLET","currency":"USD"}""" + var runs = 0 + var createdId = "" + + idempotencyService.execute(key, requestBody) { hash -> + runs++ + val cmd = + CreateAccountCommand( + name = "Idempotent Account", + type = AccountType.USER_WALLET, + currency = Currency.USD, + actor = ACTOR, + requestHash = hash, + ) + val account = accountService.create(cmd) + createdId = account.id.toString() + StoredResponse(201, """{"id":"${account.id}"}""") + } + + idempotencyService.execute(key, requestBody) { hash -> + runs++ + StoredResponse(500, "should-not-run") + } + + runs shouldBe 1 + rowsFor(createdId, AuditAction.ACCOUNT_CREATE).size shouldBe 1 + } + + companion object { + const val ACTOR = "auth0|test-actor" + const val CORR_ID = "corr-audit-it-001" + + fun sha256Hex(value: String): String = + MessageDigest + .getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + + @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/integrationTest/kotlin/com/fincore/ledger/application/TransactionBalanceServiceIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/TransactionBalanceServiceIT.kt index e8c81b5..a974709 100644 --- a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/TransactionBalanceServiceIT.kt +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/TransactionBalanceServiceIT.kt @@ -13,6 +13,7 @@ import com.fincore.ledger.domain.enum.EntryDirection 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.infrastructure.audit.AuditTrailWriterImpl import com.fincore.ledger.infrastructure.outbox.OutboxEventPublisherImpl import com.fincore.ledger.infrastructure.persistence.AccountPersistenceAdapter import com.fincore.ledger.infrastructure.persistence.OutboxEventRepository @@ -45,6 +46,7 @@ import java.time.Instant AccountPersistenceAdapter::class, TransactionBalanceServiceIT.JacksonConfig::class, OutboxEventPublisherImpl::class, + AuditTrailWriterImpl::class, ) class TransactionBalanceServiceIT( @Autowired private val transactionService: TransactionService, diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/TransactionReversalIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/TransactionReversalIT.kt index 2a1b985..114e14f 100644 --- a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/TransactionReversalIT.kt +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/TransactionReversalIT.kt @@ -12,6 +12,7 @@ import com.fincore.ledger.domain.enum.AccountType import com.fincore.ledger.domain.enum.EntryDirection import com.fincore.ledger.domain.enum.TransactionStatus import com.fincore.ledger.domain.exception.TransactionAlreadyReversedException +import com.fincore.ledger.infrastructure.audit.AuditTrailWriterImpl import com.fincore.ledger.infrastructure.outbox.OutboxEventPublisherImpl import com.fincore.ledger.infrastructure.persistence.AccountPersistenceAdapter import com.fincore.ledger.infrastructure.persistence.OutboxEventRepository @@ -43,6 +44,7 @@ import java.math.BigDecimal AccountPersistenceAdapter::class, TransactionReversalIT.JacksonConfig::class, OutboxEventPublisherImpl::class, + AuditTrailWriterImpl::class, ) class TransactionReversalIT( @Autowired private val transactionService: TransactionService, @@ -83,7 +85,7 @@ class TransactionReversalIT( val credit = newAccount() val original = postBalanced("ref-rev-1", debit.id, credit.id) - transactionService.reverse(original.id, "op", "corr-2") + transactionService.reverse(original.id, "op", "corr-2", null, null) balanceService.current(debit.id, Currency.USD).amount.isZero() shouldBe true balanceService.current(credit.id, Currency.USD).amount.isZero() shouldBe true @@ -95,7 +97,7 @@ class TransactionReversalIT( val credit = newAccount() val original = postBalanced("ref-rev-2", debit.id, credit.id) - val compensating = transactionService.reverse(original.id, "op", "corr-2") + val compensating = transactionService.reverse(original.id, "op", "corr-2", null, null) transactionService.get(original.id).status shouldBe TransactionStatus.REVERSED val detail = transactionService.get(compensating.id) @@ -109,10 +111,10 @@ class TransactionReversalIT( val debit = newAccount() val credit = newAccount() val original = postBalanced("ref-rev-3", debit.id, credit.id) - transactionService.reverse(original.id, "op", "corr-2") + transactionService.reverse(original.id, "op", "corr-2", null, null) shouldThrow { - transactionService.reverse(original.id, "op", "corr-3") + transactionService.reverse(original.id, "op", "corr-3", null, null) } } @@ -122,7 +124,7 @@ class TransactionReversalIT( val credit = newAccount() val original = postBalanced("ref-rev-4", debit.id, credit.id) - transactionService.reverse(original.id, "op", "corr-2") + transactionService.reverse(original.id, "op", "corr-2", null, null) outboxRepository.findAll().size shouldBe 2 } diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/db/LedgerSchemaMigrationIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/db/LedgerSchemaMigrationIT.kt index ab0a864..6fa03e3 100644 --- a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/db/LedgerSchemaMigrationIT.kt +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/db/LedgerSchemaMigrationIT.kt @@ -497,4 +497,12 @@ class LedgerSchemaMigrationIT { } } } + + @Test + fun `should add a nullable jsonb payload column on audit_events`() { + testDb.boolQuery( + "SELECT data_type = 'jsonb' AND is_nullable = 'YES' FROM information_schema.columns " + + "WHERE table_schema = 'platform' AND table_name = 'audit_events' AND column_name = 'payload'", + ) shouldBe true + } } diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/infrastructure/audit/AuditTrailWriterIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/infrastructure/audit/AuditTrailWriterIT.kt new file mode 100644 index 0000000..8c0eab9 --- /dev/null +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/infrastructure/audit/AuditTrailWriterIT.kt @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.infrastructure.audit + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fincore.ledger.application.AuditRecord +import com.fincore.ledger.application.AuditTrailWriter +import com.fincore.ledger.domain.enum.AuditAction +import com.fincore.ledger.domain.enum.AuditResourceType +import com.fincore.ledger.domain.enum.AuditResult +import com.fincore.ledger.infrastructure.persistence.AuditEventRepository +import com.fincore.test.containers.PostgresContainerExtension +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldNotBeBlank +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.slf4j.MDC +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.context.TestComponent +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +@ExtendWith(PostgresContainerExtension::class) +@Import( + AuditTrailWriterImpl::class, + AuditTrailWriterIT.JacksonConfig::class, + AuditTrailWriterIT.WriteHelper::class, +) +class AuditTrailWriterIT( + @Autowired private val writer: AuditTrailWriter, + @Autowired private val auditRepository: AuditEventRepository, + @Autowired private val objectMapper: ObjectMapper, + @Autowired private val helper: AuditTrailWriterIT.WriteHelper, +) { + @TestConfiguration + class JacksonConfig { + @Bean + fun objectMapper(): ObjectMapper = jacksonObjectMapper().registerModule(JavaTimeModule()) + } + + @TestComponent + class WriteHelper( + @Autowired private val writer: AuditTrailWriter, + ) { + @Transactional + fun commit(resourceId: String) { + writer.record(record(AuditAction.ACCOUNT_CREATE, resourceId, null)) + } + + @Transactional + fun rollback(resourceId: String) { + writer.record(record(AuditAction.ACCOUNT_CREATE, resourceId, null)) + throw RuntimeException("forced rollback") + } + + @Transactional + fun commitWithPayload( + resourceId: String, + payload: Map, + ) { + writer.record(record(AuditAction.ACCOUNT_STATUS_CHANGE, resourceId, payload)) + } + + private fun record( + action: AuditAction, + resourceId: String, + payload: Map?, + ) = AuditRecord( + actorId = "auth0|test", + action = action, + resourceType = AuditResourceType.ACCOUNT, + resourceId = resourceId, + requestHash = null, + payload = payload, + ) + } + + @AfterEach + fun cleanUp() { + MDC.clear() + } + + private fun rowFor(resourceId: String) = auditRepository.findAll().filter { it.resourceId == resourceId } + + @Test + fun `should commit one audit row when the surrounding transaction commits`() { + helper.commit("acc-commit-1") + + val rows = rowFor("acc-commit-1") + rows.size shouldBe 1 + rows.first().result shouldBe AuditResult.SUCCESS + rows.first().action shouldBe AuditAction.ACCOUNT_CREATE.name + } + + @Test + fun `should leave no audit row when the surrounding transaction rolls back`() { + shouldThrow { + helper.rollback("acc-rollback-1") + } + + rowFor("acc-rollback-1").size shouldBe 0 + } + + @Test + fun `should throw IllegalStateException when record is called with no active transaction`() { + shouldThrow { + writer.record( + AuditRecord( + actorId = "auth0|test", + action = AuditAction.ACCOUNT_CREATE, + resourceType = AuditResourceType.ACCOUNT, + resourceId = "acc-no-tx", + requestHash = null, + ), + ) + } + + rowFor("acc-no-tx").size shouldBe 0 + } + + @Test + fun `should store status payload when action is ACCOUNT_STATUS_CHANGE`() { + helper.commitWithPayload("acc-status-1", mapOf("status" to "FROZEN")) + + val rows = rowFor("acc-status-1") + rows.size shouldBe 1 + val payloadJson = rows.first().payload.shouldNotBeNull() + objectMapper.readTree(payloadJson).get("status").asText() shouldBe "FROZEN" + } + + @Test + fun `should populate correlationId from MDC when present`() { + MDC.put("correlation_id", "corr-it-001") + helper.commit("acc-corr-1") + + val rows = rowFor("acc-corr-1") + rows.size shouldBe 1 + rows.first().correlationId shouldBe "corr-it-001" + } + + @Test + fun `should generate a non-blank correlationId when MDC is empty`() { + MDC.clear() + helper.commit("acc-corr-2") + + val rows = rowFor("acc-corr-2") + rows.size shouldBe 1 + rows.first().correlationId.shouldNotBeBlank() + } + + @Test + fun `should store null requestHash for operations without a request body`() { + helper.commitWithPayload("acc-hash-null", mapOf("status" to "CLOSED")) + + val rows = rowFor("acc-hash-null") + rows.size shouldBe 1 + rows.first().requestHash.shouldBeNull() + } + + 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" } + } + } +} 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 index b6d9912..2052d21 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/api/AccountController.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/api/AccountController.kt @@ -95,8 +95,8 @@ class AccountController( ): ResponseEntity { var location: URI? = null val result = - idempotencyService.execute(IdempotencyKey.of(key), rawBody) { - val response = mapper.toResponse(accountService.create(mapper.toCommand(request, jwt.subject))) + idempotencyService.execute(IdempotencyKey.of(key), rawBody) { hash -> + val response = mapper.toResponse(accountService.create(mapper.toCommand(request, jwt.subject, hash))) location = URI.create("/v1/accounts/${response.id}") StoredResponse(HttpStatus.CREATED.value(), objectMapper.writeValueAsString(response)) } 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 index b187536..1f2b54d 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/api/TransactionController.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/api/TransactionController.kt @@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.fincore.core.IdempotencyKey import com.fincore.core.TransactionId import com.fincore.ledger.api.dto.request.PostTransactionRequest +import com.fincore.ledger.api.dto.request.ReverseTransactionRequest import com.fincore.ledger.api.dto.response.PageResponse import com.fincore.ledger.api.dto.response.TransactionDetailResponse import com.fincore.ledger.api.dto.response.TransactionResponse @@ -96,8 +97,8 @@ class TransactionController( ): 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))) + idempotencyService.execute(IdempotencyKey.of(key), rawBody) { hash -> + val response = mapper.toResponse(transactionService.post(mapper.toCommand(request, jwt.subject, correlationId, hash))) location = URI.create("/v1/transactions/${response.id}") StoredResponse(HttpStatus.CREATED.value(), objectMapper.writeValueAsString(response)) } @@ -175,6 +176,7 @@ class TransactionController( @PostMapping("/{id}/reverse") fun reverse( @Parameter(description = "Transaction id (tx_ prefixed ULID)") @PathVariable id: String, + @Valid @RequestBody(required = false) request: ReverseTransactionRequest?, @Parameter(hidden = true) @AuthenticationPrincipal jwt: Jwt, @RequestHeader(value = CORRELATION_HEADER, required = false) correlationId: String?, @Parameter(hidden = true) @RequestAttribute(IdempotencyAttributes.KEY) key: String, @@ -183,8 +185,11 @@ class TransactionController( val transactionId = TransactionId.fromString(id) var location: URI? = null val result = - idempotencyService.execute(IdempotencyKey.of(key), rawBody) { - val response = mapper.toResponse(transactionService.reverse(transactionId, jwt.subject, correlationId)) + idempotencyService.execute(IdempotencyKey.of(key), rawBody) { hash -> + val response = + mapper.toResponse( + transactionService.reverse(transactionId, jwt.subject, correlationId, request?.reason, hash), + ) location = URI.create("/v1/transactions/${response.id}") StoredResponse(HttpStatus.CREATED.value(), objectMapper.writeValueAsString(response)) } diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/request/ReverseTransactionRequest.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/request/ReverseTransactionRequest.kt new file mode 100644 index 0000000..87f328e --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/request/ReverseTransactionRequest.kt @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.api.dto.request + +import jakarta.validation.constraints.Size + +data class ReverseTransactionRequest( + @field:Size(max = 512) + val reason: String? = null, +) 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 b266b80..75ced24 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 @@ -37,12 +37,14 @@ class LedgerApiMapper { fun toCommand( request: CreateAccountRequest, actor: String, + requestHash: String?, ): CreateAccountCommand = CreateAccountCommand( name = request.name, type = request.type, currency = Currency.of(request.currency), actor = actor, + requestHash = requestHash, ) fun toResponse(account: Account): AccountResponse = @@ -75,6 +77,7 @@ class LedgerApiMapper { request: PostTransactionRequest, actor: String, correlationId: String?, + requestHash: String?, ): PostTransactionCommand = PostTransactionCommand( reference = request.reference, @@ -90,6 +93,7 @@ class LedgerApiMapper { }, actor = actor, correlationId = correlationId, + requestHash = requestHash, ) fun toResponse(posted: PostedTransaction): TransactionResponse = diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/AccountServiceImpl.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/AccountServiceImpl.kt index c1f8d4a..c319923 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/application/AccountServiceImpl.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/AccountServiceImpl.kt @@ -6,6 +6,8 @@ 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.enum.AuditAction +import com.fincore.ledger.domain.enum.AuditResourceType import com.fincore.ledger.domain.exception.AccountNotFoundException import com.fincore.ledger.domain.exception.DomainException import com.fincore.ledger.infrastructure.persistence.AccountBalanceRepository @@ -23,12 +25,22 @@ class AccountServiceImpl( private val accountRepository: AccountRepository, private val balanceRepository: AccountBalanceRepository, private val adapter: AccountPersistenceAdapter, + private val auditWriter: AuditTrailWriter, ) : 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) + auditWriter.record( + AuditRecord( + actorId = command.actor, + action = AuditAction.ACCOUNT_CREATE, + resourceType = AuditResourceType.ACCOUNT, + resourceId = account.id.toString(), + requestHash = command.requestHash, + ), + ) return adapter.toDomain(entity) } @@ -62,7 +74,17 @@ class AccountServiceImpl( account.rename(newName) entity.name = account.name entity.updatedBy = actor - return adapter.toDomain(accountRepository.saveAndFlush(entity)) + val saved = adapter.toDomain(accountRepository.saveAndFlush(entity)) + auditWriter.record( + AuditRecord( + actorId = actor, + action = AuditAction.ACCOUNT_RENAME, + resourceType = AuditResourceType.ACCOUNT, + resourceId = id.toString(), + requestHash = null, + ), + ) + return saved } @Transactional @@ -79,7 +101,18 @@ class AccountServiceImpl( account.transitionStatus(target) entity.status = account.status entity.updatedBy = actor - return adapter.toDomain(accountRepository.saveAndFlush(entity)) + val saved = adapter.toDomain(accountRepository.saveAndFlush(entity)) + auditWriter.record( + AuditRecord( + actorId = actor, + action = AuditAction.ACCOUNT_STATUS_CHANGE, + resourceType = AuditResourceType.ACCOUNT, + resourceId = id.toString(), + requestHash = null, + payload = mapOf("status" to target.name), + ), + ) + return saved } private fun load(id: AccountId): AccountEntity = accountRepository.findById(id.value).orElseThrow { AccountNotFoundException(id) } diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/AuditTrailWriter.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/AuditTrailWriter.kt new file mode 100644 index 0000000..14eb294 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/AuditTrailWriter.kt @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.application + +import com.fincore.ledger.domain.enum.AuditAction +import com.fincore.ledger.domain.enum.AuditResourceType + +@Suppress("LongParameterList") +data class AuditRecord( + val actorId: String, + val action: AuditAction, + val resourceType: AuditResourceType, + val resourceId: String, + val requestHash: String?, + val payload: Map? = null, +) + +interface AuditTrailWriter { + fun record(record: AuditRecord) +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/CreateAccountCommand.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/CreateAccountCommand.kt index b0c1dfc..efee541 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/application/CreateAccountCommand.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/CreateAccountCommand.kt @@ -11,4 +11,5 @@ data class CreateAccountCommand( val type: AccountType, val currency: Currency, val actor: String, + val requestHash: String? = null, ) diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyService.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyService.kt index ef335a5..9ac51df 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyService.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyService.kt @@ -20,6 +20,6 @@ interface IdempotencyService { fun execute( key: IdempotencyKey, requestBody: String, - action: () -> StoredResponse, + action: (String) -> StoredResponse, ): IdempotentResult } diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyServiceImpl.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyServiceImpl.kt index d671be3..4df02de 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyServiceImpl.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyServiceImpl.kt @@ -15,7 +15,7 @@ class IdempotencyServiceImpl( override fun execute( key: IdempotencyKey, requestBody: String, - action: () -> StoredResponse, + action: (String) -> StoredResponse, ): IdempotentResult { val keyHash = sha256Hex(key.value) val requestHash = sha256Hex(requestBody) diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyStore.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyStore.kt index e8a0104..94e6a1f 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyStore.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyStore.kt @@ -26,7 +26,7 @@ class IdempotencyStore( fun runOrReplay( keyHash: String, requestHash: String, - action: () -> StoredResponse, + action: (String) -> StoredResponse, ): IdempotentResult { val now = Instant.now() val existing = repository.findById(keyHash).orElse(null) @@ -46,7 +46,7 @@ class IdempotencyStore( } catch (duplicate: DataIntegrityViolationException) { throw IdempotencyRaceException(duplicate) } - val response = action() + val response = action(requestHash) reservation.statusCode = response.statusCode reservation.responseBody = response.responseBody repository.saveAndFlush(reservation) diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/PostTransactionCommand.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/PostTransactionCommand.kt index 98f558b..b91962e 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/application/PostTransactionCommand.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/PostTransactionCommand.kt @@ -21,4 +21,5 @@ data class PostTransactionCommand( val entries: List, val actor: String, val correlationId: String?, + val requestHash: String? = null, ) diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/TransactionPoster.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/TransactionPoster.kt index c23ab50..e70ed4e 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/application/TransactionPoster.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/TransactionPoster.kt @@ -15,6 +15,8 @@ import com.fincore.ledger.application.event.TransactionPostedPayload import com.fincore.ledger.domain.Entry import com.fincore.ledger.domain.Transaction import com.fincore.ledger.domain.enum.AccountStatus +import com.fincore.ledger.domain.enum.AuditAction +import com.fincore.ledger.domain.enum.AuditResourceType import com.fincore.ledger.domain.enum.TransactionStatus import com.fincore.ledger.domain.exception.AccountNotFoundException import com.fincore.ledger.domain.exception.DomainException @@ -43,6 +45,7 @@ class TransactionPoster( private val entryRepository: EntryRepository, private val balanceRepository: AccountBalanceRepository, private val outboxEventPublisher: OutboxEventPublisher, + private val auditWriter: AuditTrailWriter, private val adapter: TransactionPersistenceAdapter, ) { @Transactional @@ -58,6 +61,15 @@ class TransactionPoster( } catch (duplicate: DataIntegrityViolationException) { throw DuplicateTransactionException(command.reference, duplicate) } + auditWriter.record( + AuditRecord( + actorId = command.actor, + action = AuditAction.TRANSACTION_POST, + resourceType = AuditResourceType.TRANSACTION, + resourceId = transaction.id.toString(), + requestHash = command.requestHash, + ), + ) return PostedTransaction(transaction.id, transaction.reference, transaction.status, postedAt) } @@ -66,6 +78,8 @@ class TransactionPoster( originalId: TransactionId, actor: String, correlationId: String?, + reason: String?, + requestHash: String?, ): PostedTransaction { val original = transactionRepository.findById(originalId.value).orElseThrow { TransactionNotFoundException(originalId) } @@ -81,9 +95,33 @@ class TransactionPoster( } catch (conflict: DataIntegrityViolationException) { throw TransactionAlreadyReversedException(originalId, conflict) } + recordReversalAudit(originalId, actor, requestHash, reason, compensating.id.toString()) return PostedTransaction(compensating.id, compensating.reference, compensating.status, postedAt) } + private fun recordReversalAudit( + originalId: TransactionId, + actor: String, + requestHash: String?, + reason: String?, + compensatingTransactionId: String, + ) { + auditWriter.record( + AuditRecord( + actorId = actor, + action = AuditAction.TRANSACTION_REVERSE, + resourceType = AuditResourceType.TRANSACTION, + resourceId = originalId.toString(), + requestHash = requestHash, + payload = + buildMap { + put("compensatingTransactionId", compensatingTransactionId) + reason?.let { put("reason", it) } + }, + ), + ) + } + private fun buildDomain(command: PostTransactionCommand): Transaction { val entries = command.entries.map { line -> diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/TransactionService.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/TransactionService.kt index 984909e..2ddb18f 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/application/TransactionService.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/TransactionService.kt @@ -14,6 +14,8 @@ interface TransactionService { id: TransactionId, actor: String, correlationId: String?, + reason: String?, + requestHash: String?, ): PostedTransaction fun list( diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/TransactionServiceImpl.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/TransactionServiceImpl.kt index 9bd436f..751ec84 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/application/TransactionServiceImpl.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/TransactionServiceImpl.kt @@ -29,7 +29,9 @@ class TransactionServiceImpl( id: TransactionId, actor: String, correlationId: String?, - ): PostedTransaction = withOptimisticRetry { poster.postReversal(id, actor, correlationId) } + reason: String?, + requestHash: String?, + ): PostedTransaction = withOptimisticRetry { poster.postReversal(id, actor, correlationId, reason, requestHash) } @Transactional(readOnly = true) override fun get(id: TransactionId): TransactionDetail { diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/domain/enum/AuditAction.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/domain/enum/AuditAction.kt new file mode 100644 index 0000000..5299526 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/domain/enum/AuditAction.kt @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.domain.enum + +enum class AuditAction { + ACCOUNT_CREATE, + ACCOUNT_RENAME, + ACCOUNT_STATUS_CHANGE, + TRANSACTION_POST, + TRANSACTION_REVERSE, +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/domain/enum/AuditResourceType.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/domain/enum/AuditResourceType.kt new file mode 100644 index 0000000..010bb8a --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/domain/enum/AuditResourceType.kt @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.domain.enum + +enum class AuditResourceType { + ACCOUNT, + TRANSACTION, +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/audit/AuditTrailWriterImpl.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/audit/AuditTrailWriterImpl.kt new file mode 100644 index 0000000..d3f5ee7 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/audit/AuditTrailWriterImpl.kt @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.infrastructure.audit + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fincore.ledger.api.observability.CorrelationIdAttributes +import com.fincore.ledger.application.AuditRecord +import com.fincore.ledger.application.AuditTrailWriter +import com.fincore.ledger.domain.enum.AuditResult +import com.fincore.ledger.infrastructure.persistence.AuditEventEntity +import com.fincore.ledger.infrastructure.persistence.AuditEventRepository +import org.slf4j.MDC +import org.springframework.stereotype.Component +import org.springframework.transaction.support.TransactionSynchronizationManager +import java.time.Instant +import java.util.UUID + +@Component +class AuditTrailWriterImpl( + private val auditRepository: AuditEventRepository, + private val objectMapper: ObjectMapper, +) : AuditTrailWriter { + override fun record(record: AuditRecord) { + check(TransactionSynchronizationManager.isActualTransactionActive()) { + "AuditTrailWriter.record must be called within an active transaction" + } + auditRepository.saveAndFlush( + AuditEventEntity( + id = UUID.randomUUID(), + actorId = record.actorId, + correlationId = resolveCorrelationId(), + action = record.action.name, + resourceType = record.resourceType.name, + resourceId = record.resourceId, + result = AuditResult.SUCCESS, + requestHash = record.requestHash, + createdAt = Instant.now(), + payload = record.payload?.let { objectMapper.writeValueAsString(it) }, + ), + ) + } + + private fun resolveCorrelationId(): String { + val fromMdc = MDC.get(CorrelationIdAttributes.MDC_KEY) + return if (fromMdc.isNullOrBlank()) UUID.randomUUID().toString() else fromMdc + } +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/AuditEventEntity.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/AuditEventEntity.kt index c768993..40c47c2 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/AuditEventEntity.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/AuditEventEntity.kt @@ -11,6 +11,8 @@ import jakarta.persistence.Enumerated import jakarta.persistence.Id import jakarta.persistence.Table import org.hibernate.annotations.Immutable +import org.hibernate.annotations.JdbcTypeCode +import org.hibernate.type.SqlTypes import java.time.Instant import java.util.UUID @@ -39,4 +41,7 @@ class AuditEventEntity( var requestHash: String?, @Column(name = "created_at", nullable = false) var createdAt: Instant, + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "payload") + var payload: String? = null, ) 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 d8ca9c5..3f6574f 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 @@ -31,3 +31,6 @@ databaseChangeLog: - include: file: v0.1/018-audit-events-immutability.sql relativeToChangelogFile: true + - include: + file: v0.1/019-audit-events-payload.sql + relativeToChangelogFile: true diff --git a/services/ledger/src/main/resources/db/changelog/v0.1/019-audit-events-payload.sql b/services/ledger/src/main/resources/db/changelog/v0.1/019-audit-events-payload.sql new file mode 100644 index 0000000..dc38347 --- /dev/null +++ b/services/ledger/src/main/resources/db/changelog/v0.1/019-audit-events-payload.sql @@ -0,0 +1,6 @@ +--liquibase formatted sql +-- SPDX-License-Identifier: BUSL-1.1 +-- SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +--changeset fincore:019-audit-events-payload dbms:postgresql +ALTER TABLE platform.audit_events ADD COLUMN IF NOT EXISTS payload JSONB; 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 index 0c29d40..5a021b8 100644 --- a/services/ledger/src/test/kotlin/com/fincore/ledger/api/FakeIdempotencyService.kt +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/api/FakeIdempotencyService.kt @@ -11,12 +11,12 @@ 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 + var handler: (IdempotencyKey, String, (String) -> StoredResponse) -> IdempotentResult = RUN_ACTION override fun execute( key: IdempotencyKey, requestBody: String, - action: () -> StoredResponse, + action: (String) -> StoredResponse, ): IdempotentResult = handler(key, requestBody, action) fun reset() { @@ -24,8 +24,8 @@ class FakeIdempotencyService : IdempotencyService { } companion object { - val RUN_ACTION: (IdempotencyKey, String, () -> StoredResponse) -> IdempotentResult = { _, _, action -> - val response = action() + val RUN_ACTION: (IdempotencyKey, String, (String) -> StoredResponse) -> IdempotentResult = { _, requestBody, action -> + val response = action(requestBody) 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 index d023e9f..4000fa3 100644 --- a/services/ledger/src/test/kotlin/com/fincore/ledger/api/TransactionControllerTest.kt +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/api/TransactionControllerTest.kt @@ -293,7 +293,7 @@ class TransactionControllerTest( @Test fun `should reverse a transaction and return 201 with location and the compensating transaction`() { val compensatingId = TransactionId.generate() - every { transactionService.reverse(any(), "user-123", any()) } returns + every { transactionService.reverse(any(), "user-123", any(), any(), any()) } returns PostedTransaction(compensatingId, "reversal-of-tx", TransactionStatus.POSTED, Instant.parse("2026-06-13T10:00:00Z")) reverse(TransactionId.generate().toString()) @@ -305,14 +305,15 @@ class TransactionControllerTest( @Test fun `should return 404 when reversing an unknown transaction`() { - every { transactionService.reverse(any(), any(), any()) } throws TransactionNotFoundException(TransactionId.generate()) + every { transactionService.reverse(any(), any(), any(), any(), any()) } throws + TransactionNotFoundException(TransactionId.generate()) reverse(TransactionId.generate().toString()).andExpect(status().isNotFound) } @Test fun `should return 409 when reversing an already reversed transaction`() { - every { transactionService.reverse(any(), any(), any()) } throws + every { transactionService.reverse(any(), any(), any(), any(), any()) } throws TransactionAlreadyReversedException(TransactionId.generate()) reverse(TransactionId.generate().toString()).andExpect(status().isConflict) @@ -322,7 +323,7 @@ class TransactionControllerTest( fun `should reject a reverse without an idempotency key with 400`() { reverse(TransactionId.generate().toString(), withKey = false).andExpect(status().isBadRequest) - verify(exactly = 0) { transactionService.reverse(any(), any(), any()) } + verify(exactly = 0) { transactionService.reverse(any(), any(), any(), any(), any()) } } @Test 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 index 78e7e76..b82a96a 100644 --- 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 @@ -29,12 +29,13 @@ class LedgerApiMapperTest { @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") + val command = mapper.toCommand(CreateAccountRequest("Wallet", AccountType.USER_WALLET, "EUR"), "user-1", "hash-1") command.name shouldBe "Wallet" command.type shouldBe AccountType.USER_WALLET command.currency shouldBe Currency.EUR command.actor shouldBe "user-1" + command.requestHash shouldBe "hash-1" } @Test @@ -76,11 +77,12 @@ class LedgerApiMapperTest { ), ) - val command = mapper.toCommand(request, "user-1", "corr-1") + val command = mapper.toCommand(request, "user-1", "corr-1", "hash-2") command.currency shouldBe Currency.EUR command.actor shouldBe "user-1" command.correlationId shouldBe "corr-1" + command.requestHash shouldBe "hash-2" command.entries[0].accountId shouldBe a command.entries[0].amount.compareTo(BigDecimal("100.00")) shouldBe 0 command.entries[1].accountId shouldBe b diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/application/AccountServiceImplTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/application/AccountServiceImplTest.kt index 9e05013..3253438 100644 --- a/services/ledger/src/test/kotlin/com/fincore/ledger/application/AccountServiceImplTest.kt +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/application/AccountServiceImplTest.kt @@ -29,7 +29,8 @@ import java.util.UUID class AccountServiceImplTest { private val accountRepository = mockk() private val balanceRepository = mockk() - private val service = AccountServiceImpl(accountRepository, balanceRepository, AccountPersistenceAdapter()) + private val auditWriter = mockk(relaxed = true) + private val service = AccountServiceImpl(accountRepository, balanceRepository, AccountPersistenceAdapter(), auditWriter) private val now = Instant.parse("2026-06-05T12:00:00Z") diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/application/TransactionPosterTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/application/TransactionPosterTest.kt index 1332655..059a147 100644 --- a/services/ledger/src/test/kotlin/com/fincore/ledger/application/TransactionPosterTest.kt +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/application/TransactionPosterTest.kt @@ -44,6 +44,7 @@ class TransactionPosterTest { private val entryRepository = mockk() private val balanceRepository = mockk() private val outboxEventPublisher = mockk() + private val auditWriter = mockk(relaxed = true) private val poster = TransactionPoster( accountRepository, @@ -51,6 +52,7 @@ class TransactionPosterTest { entryRepository, balanceRepository, outboxEventPublisher, + auditWriter, TransactionPersistenceAdapter(), ) @@ -180,7 +182,7 @@ class TransactionPosterTest { every { balanceRepository.saveAndFlush(any()) } answers { firstArg() } justRun { outboxEventPublisher.publish(any(), any(), any(), any(), any()) } - val result = poster.postReversal(originalId, "op", "corr-1") + val result = poster.postReversal(originalId, "op", "corr-1", null, null) result.reference shouldBe "reversal-of-$originalId" original.status shouldBe TransactionStatus.REVERSED @@ -198,7 +200,7 @@ class TransactionPosterTest { every { transactionRepository.findById(originalId.value) } returns Optional.of(transactionEntity(originalId.value, TransactionStatus.REVERSED)) - shouldThrow { poster.postReversal(originalId, "op", null) } + shouldThrow { poster.postReversal(originalId, "op", null, null, null) } verify(exactly = 0) { entryRepository.saveAndFlush(any()) } } @@ -208,6 +210,6 @@ class TransactionPosterTest { val originalId = TransactionId.generate() every { transactionRepository.findById(originalId.value) } returns Optional.empty() - shouldThrow { poster.postReversal(originalId, "op", null) } + shouldThrow { poster.postReversal(originalId, "op", null, null, null) } } } diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/application/TransactionServiceImplTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/application/TransactionServiceImplTest.kt index d7ab23b..bca43e8 100644 --- a/services/ledger/src/test/kotlin/com/fincore/ledger/application/TransactionServiceImplTest.kt +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/application/TransactionServiceImplTest.kt @@ -86,26 +86,26 @@ class TransactionServiceImplTest { fun `should reverse through the poster`() { val id = TransactionId.generate() val compensating = PostedTransaction(TransactionId.generate(), "reversal-of-$id", TransactionStatus.POSTED, Instant.now()) - every { poster.postReversal(id, "op", "corr-1") } returns compensating + every { poster.postReversal(id, "op", "corr-1", null, null) } returns compensating - service.reverse(id, "op", "corr-1") shouldBe compensating + service.reverse(id, "op", "corr-1", null, null) shouldBe compensating - verify(exactly = 1) { poster.postReversal(id, "op", "corr-1") } + verify(exactly = 1) { poster.postReversal(id, "op", "corr-1", null, null) } } @Test fun `should retry reverse on an optimistic lock failure then succeed`() { val id = TransactionId.generate() var calls = 0 - every { poster.postReversal(id, "op", null) } answers { + every { poster.postReversal(id, "op", null, null, null) } answers { calls++ if (calls < 2) throw OptimisticLockingFailureException("version conflict") PostedTransaction(TransactionId.generate(), "reversal-of-$id", TransactionStatus.POSTED, Instant.now()) } - service.reverse(id, "op", null) + service.reverse(id, "op", null, null, null) - verify(exactly = 2) { poster.postReversal(id, "op", null) } + verify(exactly = 2) { poster.postReversal(id, "op", null, null, null) } } private val postedAt = Instant.parse("2026-06-12T08:00:00Z") diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/infrastructure/audit/AuditTrailWriterImplTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/infrastructure/audit/AuditTrailWriterImplTest.kt new file mode 100644 index 0000000..837308b --- /dev/null +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/infrastructure/audit/AuditTrailWriterImplTest.kt @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.infrastructure.audit + +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fincore.ledger.application.AuditRecord +import com.fincore.ledger.application.AuditTrailWriter +import com.fincore.ledger.domain.enum.AuditAction +import com.fincore.ledger.domain.enum.AuditResourceType +import com.fincore.ledger.domain.enum.AuditResult +import com.fincore.ledger.infrastructure.persistence.AuditEventEntity +import com.fincore.ledger.infrastructure.persistence.AuditEventRepository +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldHaveLength +import io.kotest.matchers.string.shouldNotBeBlank +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.slf4j.MDC +import org.springframework.transaction.support.TransactionSynchronizationManager + +class AuditTrailWriterImplTest { + private val auditRepository = mockk() + private val objectMapper = jacksonObjectMapper().registerModule(JavaTimeModule()) + private val writer: AuditTrailWriter = AuditTrailWriterImpl(auditRepository, objectMapper) + + @AfterEach + fun tearDown() { + clearAllMocks() + MDC.clear() + } + + @Test + fun `should throw IllegalStateException when record is called with no active transaction`() { + mockkStatic(TransactionSynchronizationManager::class) + every { TransactionSynchronizationManager.isActualTransactionActive() } returns false + + shouldThrow { + writer.record( + AuditRecord( + actorId = "auth0|actor", + action = AuditAction.ACCOUNT_CREATE, + resourceType = AuditResourceType.ACCOUNT, + resourceId = "acc_01", + requestHash = null, + ), + ) + } + + verify(exactly = 0) { auditRepository.saveAndFlush(any()) } + } + + @Test + fun `should save entity with SUCCESS result and all fields mapped when record is called in active transaction`() { + mockkStatic(TransactionSynchronizationManager::class) + every { TransactionSynchronizationManager.isActualTransactionActive() } returns true + val slot = slot() + every { auditRepository.saveAndFlush(capture(slot)) } answers { firstArg() } + MDC.put("correlation_id", "corr-test-001") + + writer.record( + AuditRecord( + actorId = "auth0|actor", + action = AuditAction.ACCOUNT_CREATE, + resourceType = AuditResourceType.ACCOUNT, + resourceId = "acc_01", + requestHash = "a".repeat(64), + ), + ) + + val saved = slot.captured + saved.actorId shouldBe "auth0|actor" + saved.action shouldBe AuditAction.ACCOUNT_CREATE.name + saved.resourceType shouldBe AuditResourceType.ACCOUNT.name + saved.resourceId shouldBe "acc_01" + saved.result shouldBe AuditResult.SUCCESS + saved.requestHash shouldBe "a".repeat(64) + saved.correlationId shouldBe "corr-test-001" + saved.createdAt.shouldNotBeNull() + saved.id.shouldNotBeNull() + } + + @Test + fun `should store serialized status payload when action is ACCOUNT_STATUS_CHANGE`() { + mockkStatic(TransactionSynchronizationManager::class) + every { TransactionSynchronizationManager.isActualTransactionActive() } returns true + val slot = slot() + every { auditRepository.saveAndFlush(capture(slot)) } answers { firstArg() } + + writer.record( + AuditRecord( + actorId = "auth0|actor", + action = AuditAction.ACCOUNT_STATUS_CHANGE, + resourceType = AuditResourceType.ACCOUNT, + resourceId = "acc_02", + requestHash = null, + payload = mapOf("status" to "FROZEN"), + ), + ) + + val payloadJson = slot.captured.payload.shouldNotBeNull() + val tree = objectMapper.readTree(payloadJson) + tree.get("status").asText() shouldBe "FROZEN" + } + + @Test + fun `should include reason and compensatingTransactionId in payload when reversal has reason`() { + mockkStatic(TransactionSynchronizationManager::class) + every { TransactionSynchronizationManager.isActualTransactionActive() } returns true + val slot = slot() + every { auditRepository.saveAndFlush(capture(slot)) } answers { firstArg() } + + writer.record( + AuditRecord( + actorId = "auth0|actor", + action = AuditAction.TRANSACTION_REVERSE, + resourceType = AuditResourceType.TRANSACTION, + resourceId = "tx_original", + requestHash = null, + payload = mapOf("reason" to "duplicate posting", "compensatingTransactionId" to "tx_comp_01"), + ), + ) + + val payloadJson = slot.captured.payload.shouldNotBeNull() + val tree = objectMapper.readTree(payloadJson) + tree.has("reason").shouldBeTrue() + tree.get("reason").asText() shouldBe "duplicate posting" + tree.has("compensatingTransactionId").shouldBeTrue() + tree.get("compensatingTransactionId").asText() shouldBe "tx_comp_01" + } + + @Test + fun `should omit reason key from payload when reversal has no reason`() { + mockkStatic(TransactionSynchronizationManager::class) + every { TransactionSynchronizationManager.isActualTransactionActive() } returns true + val slot = slot() + every { auditRepository.saveAndFlush(capture(slot)) } answers { firstArg() } + + writer.record( + AuditRecord( + actorId = "auth0|actor", + action = AuditAction.TRANSACTION_REVERSE, + resourceType = AuditResourceType.TRANSACTION, + resourceId = "tx_original", + requestHash = null, + payload = mapOf("compensatingTransactionId" to "tx_comp_02"), + ), + ) + + val payloadJson = slot.captured.payload.shouldNotBeNull() + val tree = objectMapper.readTree(payloadJson) + tree.has("reason").shouldBeFalse() + tree.has("compensatingTransactionId").shouldBeTrue() + } + + @Test + fun `should store null payload when no context payload is provided`() { + mockkStatic(TransactionSynchronizationManager::class) + every { TransactionSynchronizationManager.isActualTransactionActive() } returns true + val slot = slot() + every { auditRepository.saveAndFlush(capture(slot)) } answers { firstArg() } + + writer.record( + AuditRecord( + actorId = "auth0|actor", + action = AuditAction.ACCOUNT_CREATE, + resourceType = AuditResourceType.ACCOUNT, + resourceId = "acc_03", + requestHash = null, + ), + ) + + slot.captured.payload.shouldBeNull() + } + + @Test + fun `should generate a non-blank correlationId when MDC is empty`() { + mockkStatic(TransactionSynchronizationManager::class) + every { TransactionSynchronizationManager.isActualTransactionActive() } returns true + val slot = slot() + every { auditRepository.saveAndFlush(capture(slot)) } answers { firstArg() } + MDC.clear() + + writer.record( + AuditRecord( + actorId = "auth0|actor", + action = AuditAction.ACCOUNT_RENAME, + resourceType = AuditResourceType.ACCOUNT, + resourceId = "acc_04", + requestHash = null, + ), + ) + + slot.captured.correlationId.shouldNotBeBlank() + } + + @Test + fun `should store requestHash as null when operation has no request body`() { + mockkStatic(TransactionSynchronizationManager::class) + every { TransactionSynchronizationManager.isActualTransactionActive() } returns true + val slot = slot() + every { auditRepository.saveAndFlush(capture(slot)) } answers { firstArg() } + + writer.record( + AuditRecord( + actorId = "auth0|actor", + action = AuditAction.ACCOUNT_STATUS_CHANGE, + resourceType = AuditResourceType.ACCOUNT, + resourceId = "acc_05", + requestHash = null, + payload = mapOf("status" to "CLOSED"), + ), + ) + + slot.captured.requestHash.shouldBeNull() + } + + @Test + fun `should store a 64-char requestHash when provided`() { + mockkStatic(TransactionSynchronizationManager::class) + every { TransactionSynchronizationManager.isActualTransactionActive() } returns true + val slot = slot() + every { auditRepository.saveAndFlush(capture(slot)) } answers { firstArg() } + + val hash = "b".repeat(64) + writer.record( + AuditRecord( + actorId = "auth0|actor", + action = AuditAction.TRANSACTION_POST, + resourceType = AuditResourceType.TRANSACTION, + resourceId = "tx_06", + requestHash = hash, + ), + ) + + slot.captured.requestHash.shouldNotBeNull() shouldHaveLength 64 + } +}