diff --git a/services/compliance/src/main/kotlin/com/fincore/compliance/application/sanctions/SanctionsProvider.kt b/services/compliance/src/main/kotlin/com/fincore/compliance/application/sanctions/SanctionsProvider.kt new file mode 100644 index 0000000..0a8ca17 --- /dev/null +++ b/services/compliance/src/main/kotlin/com/fincore/compliance/application/sanctions/SanctionsProvider.kt @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.compliance.application.sanctions + +/** + * Plug-in port for screening a subject against a sanctions source. + * + * Real screening adapters and lists are supplied out of tree and are NOT part of this open-source service; the only + * in-tree implementation is a deterministic sandbox. The contract is generic and encodes no real list, entry, format, + * or business threshold. + * + * Implementations perform no persistence; the caller decides what to do with the result, outside any transaction. + * A business outcome is returned as a [SanctionsScreeningResult]; a technical or transient failure is thrown as a + * [SanctionsProviderException]. + */ +interface SanctionsProvider { + fun screen(request: SanctionsScreeningRequest): SanctionsScreeningResult +} diff --git a/services/compliance/src/main/kotlin/com/fincore/compliance/application/sanctions/SanctionsProviderException.kt b/services/compliance/src/main/kotlin/com/fincore/compliance/application/sanctions/SanctionsProviderException.kt new file mode 100644 index 0000000..fddc27e --- /dev/null +++ b/services/compliance/src/main/kotlin/com/fincore/compliance/application/sanctions/SanctionsProviderException.kt @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.compliance.application.sanctions + +/** Signals a technical or transient failure running a sanctions screening, distinct from a business decision. */ +class SanctionsProviderException( + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/services/compliance/src/main/kotlin/com/fincore/compliance/application/sanctions/SanctionsScreeningRequest.kt b/services/compliance/src/main/kotlin/com/fincore/compliance/application/sanctions/SanctionsScreeningRequest.kt new file mode 100644 index 0000000..32c08ec --- /dev/null +++ b/services/compliance/src/main/kotlin/com/fincore/compliance/application/sanctions/SanctionsScreeningRequest.kt @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.compliance.application.sanctions + +import com.fincore.compliance.domain.KycSession + +/** + * A request to screen a subject with a configurable m-of-n partial match. + * + * [subjectReference] is an opaque token identifying the subject, never raw PII; the provider resolves the actual + * subject data out of tree by this reference (same model as the KYC port). [attributes] is the set of generic + * attribute-key dimensions to screen ("n"), never raw PII values. [requiredMatches] is the minimum dimensions that + * must match for a potential hit ("m"), where 1 <= m <= n. + */ +data class SanctionsScreeningRequest( + val subjectReference: String, + val attributes: Set, + val requiredMatches: Int, +) { + init { + require(subjectReference.isNotBlank() && subjectReference.length <= KycSession.MAX_SUBJECT_REFERENCE_LENGTH) { + "subjectReference must be non-blank and at most ${KycSession.MAX_SUBJECT_REFERENCE_LENGTH} characters" + } + require(attributes.isNotEmpty()) { "attributes must not be empty" } + require(requiredMatches in 1..attributes.size) { + "requiredMatches must be between 1 and the number of attributes" + } + } +} diff --git a/services/compliance/src/main/kotlin/com/fincore/compliance/application/sanctions/SanctionsScreeningResult.kt b/services/compliance/src/main/kotlin/com/fincore/compliance/application/sanctions/SanctionsScreeningResult.kt new file mode 100644 index 0000000..2ed5b50 --- /dev/null +++ b/services/compliance/src/main/kotlin/com/fincore/compliance/application/sanctions/SanctionsScreeningResult.kt @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.compliance.application.sanctions + +/** + * Outcome of a [SanctionsProvider] screening: a business decision, distinct from a technical failure (a thrown + * [SanctionsProviderException]). + * + * [matchedAttributes] and [missing] carry generic attribute keys, never PII or raw subject values. + * [InsufficientData] is a first-class outcome: the screening could not be decided for want of the listed attributes. + */ +sealed interface SanctionsScreeningResult { + data object Clear : SanctionsScreeningResult + + /** + * A potential hit. [score] is a generic provider-reported match confidence in [0, 1] (higher means a stronger + * match); it is not an m-of-n ratio and not a business risk band. + */ + data class PotentialMatch( + val matchedAttributes: List, + val score: Double, + ) : SanctionsScreeningResult { + init { + require(matchedAttributes.isNotEmpty()) { "matchedAttributes must not be empty" } + require(score in MIN_SCORE..MAX_SCORE) { "score must be within [$MIN_SCORE, $MAX_SCORE]" } + } + + private companion object { + const val MIN_SCORE = 0.0 + const val MAX_SCORE = 1.0 + } + } + + data class InsufficientData( + val missing: List, + ) : SanctionsScreeningResult { + init { + require(missing.isNotEmpty()) { "missing must not be empty" } + } + } +} diff --git a/services/compliance/src/test/kotlin/com/fincore/compliance/application/sanctions/SanctionsProviderContractTest.kt b/services/compliance/src/test/kotlin/com/fincore/compliance/application/sanctions/SanctionsProviderContractTest.kt new file mode 100644 index 0000000..d977f0f --- /dev/null +++ b/services/compliance/src/test/kotlin/com/fincore/compliance/application/sanctions/SanctionsProviderContractTest.kt @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.compliance.application.sanctions + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import org.junit.jupiter.api.Test + +private class FixedSanctionsProvider( + private val result: SanctionsScreeningResult, +) : SanctionsProvider { + override fun screen(request: SanctionsScreeningRequest): SanctionsScreeningResult = result +} + +class SanctionsProviderContractTest { + private val request = SanctionsScreeningRequest("subject-1", setOf("attr-a", "attr-b"), 1) + + @Test + fun `should expose a single screen method when inspected`() { + SanctionsProvider::class.java.isInterface shouldBe true + + val method = + SanctionsProvider::class.java.declaredMethods + .filter { !it.isBridge && !it.isSynthetic } + .single { it.name == "screen" } + + method.returnType shouldBe SanctionsScreeningResult::class.java + method.parameterTypes.toList() shouldBe listOf(SanctionsScreeningRequest::class.java) + } + + @Test + fun `should return clear when the implementation finds no hit`() { + FixedSanctionsProvider(SanctionsScreeningResult.Clear) + .screen(request) + .shouldBeInstanceOf() + } + + @Test + fun `should carry matched attributes and score on a potential match`() { + val result = FixedSanctionsProvider(SanctionsScreeningResult.PotentialMatch(listOf("attr-a"), 0.5)).screen(request) + + val match = result.shouldBeInstanceOf() + match.matchedAttributes shouldBe listOf("attr-a") + match.score shouldBe 0.5 + } + + @Test + fun `should carry the missing attributes when data is insufficient`() { + val result = FixedSanctionsProvider(SanctionsScreeningResult.InsufficientData(listOf("attr-b"))).screen(request) + + result.shouldBeInstanceOf().missing shouldBe listOf("attr-b") + } + + @Test + fun `should reject a blank subject reference`() { + shouldThrow { SanctionsScreeningRequest(" ", setOf("attr-a"), 1) } + } + + @Test + fun `should reject empty attributes`() { + shouldThrow { SanctionsScreeningRequest("subject-1", emptySet(), 1) } + } + + @Test + fun `should reject required matches below one`() { + shouldThrow { SanctionsScreeningRequest("subject-1", setOf("attr-a"), 0) } + } + + @Test + fun `should reject required matches above the attribute count`() { + shouldThrow { SanctionsScreeningRequest("subject-1", setOf("attr-a"), 2) } + } + + @Test + fun `should reject a potential match with no matched attributes`() { + shouldThrow { SanctionsScreeningResult.PotentialMatch(emptyList(), 0.5) } + } + + @Test + fun `should reject a potential match score out of range`() { + shouldThrow { SanctionsScreeningResult.PotentialMatch(listOf("attr-a"), 1.5) } + } + + @Test + fun `should reject insufficient data with no missing attributes`() { + shouldThrow { SanctionsScreeningResult.InsufficientData(emptyList()) } + } + + @Test + fun `should reject a not-a-number score`() { + shouldThrow { SanctionsScreeningResult.PotentialMatch(listOf("attr-a"), Double.NaN) } + } + + @Test + fun `should propagate a provider exception on technical failure`() { + val provider = + object : SanctionsProvider { + override fun screen(request: SanctionsScreeningRequest): SanctionsScreeningResult = + throw SanctionsProviderException("upstream unavailable") + } + + shouldThrow { provider.screen(request) } + } +}