diff --git a/build.gradle.kts b/build.gradle.kts index cb3f40b..d55d320 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.spring) apply false alias(libs.plugins.kotlin.jpa) apply false + alias(libs.plugins.kotlin.kapt) apply false alias(libs.plugins.spring.boot) apply false alias(libs.plugins.spring.dependency.management) apply false alias(libs.plugins.ksp) apply false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6eb99c5..5454648 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -84,6 +84,7 @@ junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.re kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-spring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" } kotlin-jpa = { id = "org.jetbrains.kotlin.plugin.jpa", version.ref = "kotlin" } +kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" } spring-dependency-management = { id = "io.spring.dependency-management", version.ref = "spring-dependency-management" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/libs/fincore-events/src/main/kotlin/com/fincore/events/OutboxEvent.kt b/libs/fincore-events/src/main/kotlin/com/fincore/events/OutboxEvent.kt index 839a4a0..1bf77f0 100644 --- a/libs/fincore-events/src/main/kotlin/com/fincore/events/OutboxEvent.kt +++ b/libs/fincore-events/src/main/kotlin/com/fincore/events/OutboxEvent.kt @@ -17,5 +17,4 @@ data class OutboxEvent( val publishedAt: Instant?, val attempts: Int, val lastError: String?, - val version: Long, ) diff --git a/services/ledger/build.gradle.kts b/services/ledger/build.gradle.kts index e826ecb..66013d6 100644 --- a/services/ledger/build.gradle.kts +++ b/services/ledger/build.gradle.kts @@ -7,8 +7,7 @@ plugins { alias(libs.plugins.kotlin.jpa) alias(libs.plugins.spring.boot) alias(libs.plugins.spring.dependency.management) - // MapStruct mappers (S4) use kapt, not KSP - MapStruct has no KSP processor. - // The kapt plugin + kapt(mapstruct-processor) get added when mappers land. + alias(libs.plugins.kotlin.kapt) } description = "FinCore Ledger service: double-entry accounts, transactions, balances" @@ -33,6 +32,9 @@ dependencies { implementation(libs.spring.boot.starter.validation) implementation(libs.springdoc.openapi.starter) + implementation(libs.mapstruct.core) + kapt(libs.mapstruct.processor) + implementation(libs.liquibase.core) runtimeOnly(libs.postgres.jdbc) 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 e489e67..ab0a864 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 @@ -469,4 +469,32 @@ class LedgerSchemaMigrationIT { "WHERE table_schema = 'platform' AND table_name = 'audit_events' AND column_name = 'version'", ) shouldBe 0 } + + @Test + fun `should reject an update on audit_events`() { + testDb.open().use { connection -> + testDb.insertAuditEvent(connection, result = "SUCCESS", resourceId = "tx_immutable_upd") + shouldThrow { + connection.createStatement().use { statement -> + statement.executeUpdate( + "UPDATE platform.audit_events SET result = 'DENIED' WHERE resource_id = 'tx_immutable_upd'", + ) + } + } + } + } + + @Test + fun `should reject a delete on audit_events`() { + testDb.open().use { connection -> + testDb.insertAuditEvent(connection, result = "SUCCESS", resourceId = "tx_immutable_del") + shouldThrow { + connection.createStatement().use { statement -> + statement.executeUpdate( + "DELETE FROM platform.audit_events WHERE resource_id = 'tx_immutable_del'", + ) + } + } + } + } } diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/infrastructure/persistence/PlatformPersistenceIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/infrastructure/persistence/PlatformPersistenceIT.kt new file mode 100644 index 0000000..74863ac --- /dev/null +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/infrastructure/persistence/PlatformPersistenceIT.kt @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.infrastructure.persistence + +import com.fincore.events.OutboxStatus +import com.fincore.ledger.domain.enum.AuditResult +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.autoconfigure.orm.jpa.TestEntityManager +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import java.time.Instant +import java.util.UUID + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ExtendWith(PostgresContainerExtension::class) +class PlatformPersistenceIT( + @Autowired private val outboxRepository: OutboxEventRepository, + @Autowired private val idempotencyRepository: IdempotencyKeyRepository, + @Autowired private val auditRepository: AuditEventRepository, + @Autowired private val entityManager: TestEntityManager, +) { + @Test + fun `should round trip an outbox event entity`() { + val id = UUID.randomUUID() + outboxRepository.saveAndFlush( + OutboxEventEntity( + id = id, + aggregateType = "Account", + aggregateId = "acc_01HZX", + eventType = "com.fincore.ledger.account.created.v1", + payload = "{\"name\": \"acct\"}", + status = OutboxStatus.PENDING, + createdAt = Instant.parse("2026-06-05T12:00:00Z"), + publishedAt = null, + attempts = 0, + lastError = null, + ), + ) + entityManager.clear() + + val loaded = outboxRepository.findById(id).orElseThrow() + loaded.aggregateId shouldBe "acc_01HZX" + loaded.status shouldBe OutboxStatus.PENDING + loaded.payload shouldBe "{\"name\": \"acct\"}" + loaded.attempts shouldBe 0 + loaded.publishedAt shouldBe null + } + + @Test + fun `should round trip an idempotency key entity`() { + val keyHash = "a".repeat(64) + idempotencyRepository.saveAndFlush( + IdempotencyKeyEntity( + keyHash = keyHash, + requestHash = "b".repeat(64), + statusCode = null, + responseBody = null, + createdAt = Instant.parse("2026-06-05T12:00:00Z"), + expiresAt = Instant.parse("2026-06-06T12:00:00Z"), + ), + ) + entityManager.clear() + + val loaded = idempotencyRepository.findById(keyHash).orElseThrow() + loaded.requestHash shouldBe "b".repeat(64) + loaded.statusCode shouldBe null + loaded.responseBody shouldBe null + } + + @Test + fun `should round trip an audit event entity`() { + val id = UUID.randomUUID() + auditRepository.saveAndFlush( + AuditEventEntity( + id = id, + actorId = "auth0|operator", + correlationId = "corr-1", + action = "ACCOUNT_CREATE", + resourceType = "ACCOUNT", + resourceId = "acc_01HZX", + result = AuditResult.SUCCESS, + requestHash = null, + createdAt = Instant.parse("2026-06-05T12:00:00Z"), + ), + ) + entityManager.clear() + + val loaded = auditRepository.findById(id).orElseThrow() + loaded.actorId shouldBe "auth0|operator" + loaded.result shouldBe AuditResult.SUCCESS + loaded.requestHash shouldBe null + } + + 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/domain/enum/AuditResult.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/domain/enum/AuditResult.kt new file mode 100644 index 0000000..24c93fa --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/domain/enum/AuditResult.kt @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.domain.enum + +enum class AuditResult { + SUCCESS, + FAILURE, + DENIED, +} 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 new file mode 100644 index 0000000..c768993 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/AuditEventEntity.kt @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.infrastructure.persistence + +import com.fincore.ledger.domain.enum.AuditResult +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Table +import org.hibernate.annotations.Immutable +import java.time.Instant +import java.util.UUID + +@Entity +@Immutable +@Table(name = "audit_events", schema = "platform") +@Suppress("LongParameterList") +class AuditEventEntity( + @Id + @Column(name = "id", nullable = false, updatable = false) + var id: UUID, + @Column(name = "actor_id", nullable = false) + var actorId: String, + @Column(name = "correlation_id", nullable = false) + var correlationId: String, + @Column(name = "action", nullable = false) + var action: String, + @Column(name = "resource_type", nullable = false) + var resourceType: String, + @Column(name = "resource_id", nullable = false) + var resourceId: String, + @Enumerated(EnumType.STRING) + @Column(name = "result", nullable = false) + var result: AuditResult, + @Column(name = "request_hash") + var requestHash: String?, + @Column(name = "created_at", nullable = false) + var createdAt: Instant, +) diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/AuditEventRepository.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/AuditEventRepository.kt new file mode 100644 index 0000000..fa5240c --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/AuditEventRepository.kt @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.infrastructure.persistence + +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface AuditEventRepository : JpaRepository diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/IdempotencyKeyEntity.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/IdempotencyKeyEntity.kt new file mode 100644 index 0000000..cface1e --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/IdempotencyKeyEntity.kt @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.infrastructure.persistence + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import org.hibernate.annotations.JdbcTypeCode +import org.hibernate.type.SqlTypes +import java.time.Instant + +@Entity +@Table(name = "idempotency_keys", schema = "platform") +class IdempotencyKeyEntity( + @Id + @Column(name = "key_hash", nullable = false, updatable = false) + var keyHash: String, + @Column(name = "request_hash", nullable = false) + var requestHash: String, + @Column(name = "status_code") + var statusCode: Int?, + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "response_body") + var responseBody: String?, + @Column(name = "created_at", nullable = false) + var createdAt: Instant, + @Column(name = "expires_at", nullable = false) + var expiresAt: Instant, +) diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/IdempotencyKeyRepository.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/IdempotencyKeyRepository.kt new file mode 100644 index 0000000..36e43fb --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/IdempotencyKeyRepository.kt @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.infrastructure.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface IdempotencyKeyRepository : JpaRepository diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/OutboxEventEntity.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/OutboxEventEntity.kt new file mode 100644 index 0000000..678b8fa --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/OutboxEventEntity.kt @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.infrastructure.persistence + +import com.fincore.events.OutboxStatus +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Table +import org.hibernate.annotations.JdbcTypeCode +import org.hibernate.type.SqlTypes +import java.time.Instant +import java.util.UUID + +@Entity +@Table(name = "outbox_events", schema = "platform") +@Suppress("LongParameterList") +class OutboxEventEntity( + @Id + @Column(name = "id", nullable = false, updatable = false) + var id: UUID, + @Column(name = "aggregate_type", nullable = false) + var aggregateType: String, + @Column(name = "aggregate_id", nullable = false) + var aggregateId: String, + @Column(name = "event_type", nullable = false) + var eventType: String, + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "payload", nullable = false) + var payload: String, + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + var status: OutboxStatus, + @Column(name = "created_at", nullable = false) + var createdAt: Instant, + @Column(name = "published_at") + var publishedAt: Instant?, + @Column(name = "attempts", nullable = false) + var attempts: Int, + @Column(name = "last_error") + var lastError: String?, +) diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/OutboxEventMapper.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/OutboxEventMapper.kt new file mode 100644 index 0000000..bb66971 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/OutboxEventMapper.kt @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.infrastructure.persistence + +import com.fincore.events.OutboxEvent +import org.mapstruct.Mapper +import org.mapstruct.ReportingPolicy + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.ERROR) +interface OutboxEventMapper { + fun toDomain(entity: OutboxEventEntity): OutboxEvent + + fun toEntity(domain: OutboxEvent): OutboxEventEntity +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/OutboxEventRepository.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/OutboxEventRepository.kt new file mode 100644 index 0000000..f3d7938 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/OutboxEventRepository.kt @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.infrastructure.persistence + +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface OutboxEventRepository : JpaRepository 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 709a1d4..d8ca9c5 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 @@ -28,3 +28,6 @@ databaseChangeLog: - include: file: v0.1/017-audit-events.sql relativeToChangelogFile: true + - include: + file: v0.1/018-audit-events-immutability.sql + relativeToChangelogFile: true diff --git a/services/ledger/src/main/resources/db/changelog/v0.1/018-audit-events-immutability.sql b/services/ledger/src/main/resources/db/changelog/v0.1/018-audit-events-immutability.sql new file mode 100644 index 0000000..6378fb8 --- /dev/null +++ b/services/ledger/src/main/resources/db/changelog/v0.1/018-audit-events-immutability.sql @@ -0,0 +1,20 @@ +--liquibase formatted sql +-- SPDX-License-Identifier: BUSL-1.1 +-- SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +--changeset fincore:018-audit-immutability-fn dbms:postgresql runOnChange:true splitStatements:false +CREATE OR REPLACE FUNCTION platform.reject_audit_mutation() +RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION + 'platform.audit_events is append-only: % is not permitted', TG_OP + USING ERRCODE = '0A000'; +END; +$$ LANGUAGE plpgsql; + +--changeset fincore:018-audit-immutability-trigger dbms:postgresql +DROP TRIGGER IF EXISTS trg_audit_events_immutable ON platform.audit_events; +CREATE TRIGGER trg_audit_events_immutable + BEFORE UPDATE OR DELETE ON platform.audit_events + FOR EACH ROW + EXECUTE FUNCTION platform.reject_audit_mutation(); diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/infrastructure/persistence/OutboxEventMapperTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/infrastructure/persistence/OutboxEventMapperTest.kt new file mode 100644 index 0000000..ddca194 --- /dev/null +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/infrastructure/persistence/OutboxEventMapperTest.kt @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.infrastructure.persistence + +import com.fincore.events.OutboxEvent +import com.fincore.events.OutboxStatus +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import java.time.Instant +import java.util.UUID + +class OutboxEventMapperTest { + private val mapper = OutboxEventMapperImpl() + + @Test + fun `should round trip an outbox event between domain and entity`() { + val domain = + OutboxEvent( + id = UUID.fromString("00000000-0000-0000-0000-0000000000aa"), + aggregateType = "Account", + aggregateId = "acc_01HZX", + eventType = "com.fincore.ledger.account.created.v1", + payload = "{\"name\":\"acct\"}", + status = OutboxStatus.PENDING, + createdAt = Instant.parse("2026-06-05T12:00:00Z"), + publishedAt = null, + attempts = 0, + lastError = null, + ) + + mapper.toDomain(mapper.toEntity(domain)) shouldBe domain + } + + @Test + fun `should map every field onto the entity`() { + val domain = + OutboxEvent( + id = UUID.fromString("00000000-0000-0000-0000-0000000000bb"), + aggregateType = "Transaction", + aggregateId = "tx_01HZY", + eventType = "com.fincore.ledger.transaction.posted.v1", + payload = "{}", + status = OutboxStatus.FAILED, + createdAt = Instant.parse("2026-06-05T13:00:00Z"), + publishedAt = Instant.parse("2026-06-05T13:05:00Z"), + attempts = 3, + lastError = "boom", + ) + + val entity = mapper.toEntity(domain) + + entity.id shouldBe domain.id + entity.aggregateType shouldBe domain.aggregateType + entity.aggregateId shouldBe domain.aggregateId + entity.eventType shouldBe domain.eventType + entity.payload shouldBe domain.payload + entity.status shouldBe domain.status + entity.createdAt shouldBe domain.createdAt + entity.publishedAt shouldBe domain.publishedAt + entity.attempts shouldBe domain.attempts + entity.lastError shouldBe domain.lastError + } +}