diff --git a/openpay/crates/op-connect/src/lib.rs b/openpay/crates/op-connect/src/lib.rs index 25eea3b5d..e8b6e361a 100644 --- a/openpay/crates/op-connect/src/lib.rs +++ b/openpay/crates/op-connect/src/lib.rs @@ -121,7 +121,8 @@ pub use onboarding::{ }; pub use payout::{PayoutInstruction, PayoutMode, PayoutSchedule}; pub use screening::{ - ConnectScreener, Decision, ScreeningResult, annotate_pep_flags, build_pep_index_from_names, + ConnectScreener, Decision, ScreenCoverage, ScreeningResult, annotate_pep_flags, + build_pep_index_from_names, }; pub use split::{PaymentId, PaymentSplit, SplitLeg}; pub use tax_reporting::{ diff --git a/openpay/crates/op-connect/src/screening.rs b/openpay/crates/op-connect/src/screening.rs index ad2ee448e..9e5a52345 100644 --- a/openpay/crates/op-connect/src/screening.rs +++ b/openpay/crates/op-connect/src/screening.rs @@ -18,6 +18,13 @@ //! news mentions); same shape as the PEP index. //! //! The combined [`ScreeningResult`] gates the [`crate::onboarding::StepResult`]. +//! +//! Screens 3 and 4 are optional *to configure*, which makes "we did not +//! look" a reachable state. It is not the same state as "we looked and +//! found nothing", so the two are different [`Decision`]s: +//! [`ScreeningResult::coverage`] records which screens ran, and a +//! submission missing the mandated PEP screen reports +//! [`Decision::Incomplete`] rather than [`Decision::Clear`]. use std::collections::BTreeSet; @@ -31,14 +38,71 @@ use crate::error::Result; use crate::kyb::{Address, BeneficialOwner, BusinessProfile}; /// Top-level screening decision for a connected account. +/// +/// Precedence when more than one condition holds: `Block` > `Review` > +/// `Incomplete` > `Clear`. A sanctions hit outranks everything, and a +/// hit that needs adjudication outranks a missing screen because the +/// gap is reported independently in [`ScreeningResult::coverage`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Decision { - /// No hits anywhere; safe to proceed. + /// Every mandated screen ran and none of them hit; safe to proceed. + /// + /// This is a statement about screens that *ran*. A configuration + /// missing a mandated screen yields [`Decision::Incomplete`], never + /// this. Clear, /// One or more sanctions hits; block the account. Block, /// PEP or adverse-media hits, or sanctions ambiguity; route to manual review. Review, + /// A screen the platform is required to run did not run, so no + /// determination was made. + /// + /// This is **not** a clearance. An unrun screen produces no hits, + /// which is indistinguishable from a screen that ran and found + /// nothing unless the two are different decisions. + /// [`ScreeningResult::coverage`] names which screen is missing. + Incomplete, +} + +impl Decision { + /// Onboarding may continue without human intervention. + /// + /// Only [`Decision::Clear`] qualifies. [`Decision::Incomplete`] + /// deliberately does not: the absence of a hit from a screen that + /// never ran is not a negative result. + #[must_use] + pub const fn may_proceed(self) -> bool { + matches!(self, Self::Clear) + } +} + +/// Which of the optional screens actually ran for one submission. +/// +/// Recorded alongside the hit vectors so a persisted [`ScreeningResult`] +/// distinguishes "screened, found nothing" from "never screened". +/// Without it an operator holding only a sanctions feed produces a row +/// indistinguishable from a full clearance, and the gap is invisible on +/// review. +/// +/// The sanctions screen is absent from this record because it cannot be +/// absent from the run: [`ConnectScreener::new`] takes it by value. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct ScreenCoverage { + /// The PEP screen ran. Mandated by **FATF Recommendation 12** and + /// EU AMLD5 Art. 20, so `false` withholds clearance. + pub pep: bool, + /// The adverse-media screen ran. Documented as optional, so `false` + /// does not by itself withhold clearance. + pub adverse_media: bool, +} + +impl ScreenCoverage { + /// Every screen the platform is *required* to run actually ran. + #[must_use] + pub const fn mandated_screens_ran(self) -> bool { + self.pep + } } /// Combined screening outcome for one owner-roster submission. @@ -50,6 +114,12 @@ pub struct ScreeningResult { pub pep_hits: Vec, /// Adverse-media matches across all persons + business. pub adverse_media_hits: Vec, + /// Which optional screens ran. + /// + /// Defaults to "neither ran" when absent from a stored record: a row + /// that does not state its coverage cannot assert it. + #[serde(default)] + pub coverage: ScreenCoverage, /// Top-line decision. pub decision: Decision, } @@ -196,18 +266,26 @@ impl ConnectScreener { } } + let coverage = ScreenCoverage { + pep: self.pep.is_some(), + adverse_media: self.adverse_media.is_some(), + }; + let decision = if any_block { Decision::Block } else if any_review { Decision::Review - } else { + } else if coverage.mandated_screens_ran() { Decision::Clear + } else { + Decision::Incomplete }; Ok(ScreeningResult { sanctions_hits, pep_hits, adverse_media_hits, + coverage, decision, }) } @@ -361,19 +439,86 @@ mod tests { assert!(owners[0].is_pep, "PEP flag should have fired"); } + fn empty_screener() -> Screener { + Screener::new(empty_index(), ScreenerConfig::default(), AuditLog::new()) + } + + fn roster() -> Vec { + vec![BeneficialOwner { + person: person("Jane Doe"), + ownership_pct: 100.0, + control: true, + is_pep: false, + }] + } + + /// The discriminating pair, part one: no PEP feed is configured, so + /// the screen mandated by FATF Rec. 12 never runs. `pep_hits` is + /// empty because nothing looked, and that is not a clearance. + /// + /// This replaces `clear_when_lists_empty`, which built exactly this + /// configuration and asserted `Clear`. #[tokio::test] - async fn clear_when_lists_empty() { - let sanctions = Screener::new(empty_index(), ScreenerConfig::default(), AuditLog::new()); - let cs = ConnectScreener::new(sanctions, None, None); - let res = cs - .screen(&sample_business(), &[BeneficialOwner { - person: person("Jane Doe"), - ownership_pct: 100.0, - control: true, - is_pep: false, - }]) - .await - .expect("ok"); + async fn an_unrun_pep_screen_is_not_a_clearance() { + let cs = ConnectScreener::new(empty_screener(), None, None); + let res = cs.screen(&sample_business(), &roster()).await.expect("ok"); + + assert!(res.pep_hits.is_empty(), "nothing screened, so nothing hit"); + assert!(!res.coverage.pep, "the PEP screen did not run"); + assert_eq!(res.decision, Decision::Incomplete); + assert!( + !res.decision.may_proceed(), + "an unrun mandated screen must not gate onboarding open" + ); + } + + /// The discriminating pair, part two: a real PEP index is configured + /// and returns nothing. The hit vectors are identical to the test + /// above and the decision is the opposite, which is the whole reason + /// coverage is recorded. + #[tokio::test] + async fn a_pep_screen_that_ran_and_found_nothing_is_a_clearance() { + let cs = ConnectScreener::new( + empty_screener(), + Some(empty_screener()), + Some(empty_screener()), + ); + let res = cs.screen(&sample_business(), &roster()).await.expect("ok"); + + assert!(res.pep_hits.is_empty(), "the index is empty"); + assert!(res.coverage.pep); + assert!(res.coverage.adverse_media); assert_eq!(res.decision, Decision::Clear); + assert!(res.decision.may_proceed()); + } + + /// Adverse media is documented as optional, so its absence is + /// recorded but does not withhold clearance. Only the mandated + /// screen does that. + #[tokio::test] + async fn a_missing_optional_screen_still_clears_but_is_recorded() { + let cs = ConnectScreener::new(empty_screener(), Some(empty_screener()), None); + let res = cs.screen(&sample_business(), &roster()).await.expect("ok"); + + assert!(!res.coverage.adverse_media, "it did not run"); + assert_eq!(res.decision, Decision::Clear, "and it is not mandated"); + } + + /// A PEP hit still routes to review rather than blocking, and still + /// reports full coverage. + #[tokio::test] + async fn a_pep_hit_routes_to_review() { + let pep = Screener::new( + build_pep_index_from_names(&["Jane Doe"]), + ScreenerConfig::default(), + AuditLog::new(), + ); + let cs = ConnectScreener::new(empty_screener(), Some(pep), None); + let res = cs.screen(&sample_business(), &roster()).await.expect("ok"); + + assert!(!res.pep_hits.is_empty(), "the fixture names this owner"); + assert!(res.coverage.pep); + assert_eq!(res.decision, Decision::Review); + assert!(!res.decision.may_proceed()); } }