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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,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
}
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.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)
Original file line number Diff line number Diff line change
@@ -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<String>,
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"
}
}
}
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.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<String>,
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<String>,
) : SanctionsScreeningResult {
init {
require(missing.isNotEmpty()) { "missing must not be empty" }
}
}
}
Original file line number Diff line number Diff line change
@@ -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<SanctionsScreeningResult.Clear>()
}

@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<SanctionsScreeningResult.PotentialMatch>()
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<SanctionsScreeningResult.InsufficientData>().missing shouldBe listOf("attr-b")
}

@Test
fun `should reject a blank subject reference`() {
shouldThrow<IllegalArgumentException> { SanctionsScreeningRequest(" ", setOf("attr-a"), 1) }
}

@Test
fun `should reject empty attributes`() {
shouldThrow<IllegalArgumentException> { SanctionsScreeningRequest("subject-1", emptySet(), 1) }
}

@Test
fun `should reject required matches below one`() {
shouldThrow<IllegalArgumentException> { SanctionsScreeningRequest("subject-1", setOf("attr-a"), 0) }
}

@Test
fun `should reject required matches above the attribute count`() {
shouldThrow<IllegalArgumentException> { SanctionsScreeningRequest("subject-1", setOf("attr-a"), 2) }
}

@Test
fun `should reject a potential match with no matched attributes`() {
shouldThrow<IllegalArgumentException> { SanctionsScreeningResult.PotentialMatch(emptyList(), 0.5) }
}

@Test
fun `should reject a potential match score out of range`() {
shouldThrow<IllegalArgumentException> { SanctionsScreeningResult.PotentialMatch(listOf("attr-a"), 1.5) }
}

@Test
fun `should reject insufficient data with no missing attributes`() {
shouldThrow<IllegalArgumentException> { SanctionsScreeningResult.InsufficientData(emptyList()) }
}

@Test
fun `should reject a not-a-number score`() {
shouldThrow<IllegalArgumentException> { 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<SanctionsProviderException> { provider.screen(request) }
}
}
Loading