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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,4 @@ data class OutboxEvent(
val publishedAt: Instant?,
val attempts: Int,
val lastError: String?,
val version: Long,
)
6 changes: 4 additions & 2 deletions services/ledger/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SQLException> {
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<SQLException> {
connection.createStatement().use { statement ->
statement.executeUpdate(
"DELETE FROM platform.audit_events WHERE resource_id = 'tx_immutable_del'",
)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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" }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.ledger.domain.enum

enum class AuditResult {
SUCCESS,
FAILURE,
DENIED,
}
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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<AuditEventEntity, UUID>
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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<IdempotencyKeyEntity, String>
Original file line number Diff line number Diff line change
@@ -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?,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.ledger.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
}
Original file line number Diff line number Diff line change
@@ -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<OutboxEventEntity, UUID>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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();
Loading
Loading