open-standards/openpay/crates/op-connect/src/lib.rs
Transaction Science fc5081ae3f
All checks were successful
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 10s
standards workspaces build + test / macOS energy backends (pull_request) Has been skipped
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 1m21s
standards workspaces build + test / openpay (openpay, openpay, linux-arm64) (pull_request) Successful in 6m0s
op-connect: an unrun PEP screen is not a clearance
`ConnectScreener` takes its PEP and adverse-media screeners as
`Option<Screener>`, documented so that "operators who only have a
sanctions feed can still use this crate (PEP / AM are then no-ops
returning empty hit-vectors)". Both screens are gated on
`if let Some(..)`, so with `pep: None` neither `pep_hits` nor
`any_review` is ever touched, and the fall-through returned
`Decision::Clear` — whose own doc comment read "No hits anywhere; safe
to proceed."

The module doc states that at each BeneficialOwners submission the
platform *must* "screen every person against a PEP watchlist ... per
FATF Recommendation 12 and EU AMLD5 Art. 20". So the crate emitted
"safe to proceed" for a screen it is required to run and did not run.
`ScreeningResult` recorded no field saying which screens had run, so
the persisted row was indistinguishable from one where a real PEP feed
was queried and returned nothing: a sitting head of state onboarded
under a sanctions-only configuration produced the same record as a
genuine three-screen clearance, and the gap was invisible on review.
The crate's own test `clear_when_lists_empty` built exactly that
configuration and asserted `Clear`, pinning the behaviour.

Unknown is now a distinct value rather than the best possible one:

- `ScreenCoverage { pep, adverse_media }` records which optional
  screens ran, and travels with the hits. Sanctions is absent from it
  because it cannot be absent from the run — `new` takes it by value,
  so a bool could never be false. `#[serde(default)]` makes a stored
  row that does not state its coverage default to "neither ran": a
  record that does not say cannot assert it.
- `Decision::Incomplete` reports a mandated screen that did not run.
  Precedence is `Block` > `Review` > `Incomplete` > `Clear`; a hit
  needing adjudication outranks a missing screen because the gap is
  reported independently in `coverage`.
- `Decision::may_proceed()` is true only for `Clear`, so a caller
  cannot read `Incomplete` as proceedable by forgetting a match arm.

The mandated/optional line is drawn from the crate's own doc: items
1-3 are "must", item 4 is "Optionally", so a missing adverse-media
screen is recorded but still clears, and a missing PEP screen does not.

`clear_when_lists_empty` is replaced by a discriminating pair with
identical empty hit-vectors and opposite decisions — unconfigured PEP
yields `Incomplete`, a configured-but-empty PEP index yields `Clear` —
which is the distinction the old single test could not express.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-17 04:13:10 -04:00

133 lines
5.5 KiB
Rust

//! # `op-connect` — Platform / marketplace tier for OpenPay
//!
//! `op-connect` is the Stripe-Connect / Adyen-MarketPay / Square-Capital
//! equivalent inside OpenPay. A platform that wants to onboard
//! sub-merchants, KYB them, split payments across multiple
//! beneficiaries, run a payout schedule, hold rolling reserves, and
//! file 1099-Ks plugs this crate in between [`op-core`](../op_core)
//! (payment primitives) and the rail-driver crates (`op-batch`,
//! `op-rails-a2a`, `op-rails-card`).
//!
//! ## What this crate ships
//!
//! - [`account`] — connected-account model (Standard / Express / Custom),
//! capability set, requirements vector.
//! - [`kyb`] — Know-Your-Business profile, beneficial-owner model,
//! FinCEN CDD (31 CFR § 1010.230) and EU AMLD5 (Directive (EU)
//! 2018/843, Art. 3(6)) compliance helpers.
//! - [`onboarding`] — stepwise onboarding state machine and the
//! [`OnboardingProvider`](onboarding::OnboardingProvider) trait that
//! isolates side-effects from policy. Ships a native in-process
//! provider and documents the adapter shape for Stripe Connect /
//! Adyen MarketPay migration.
//! - [`screening`] — wires the [`op-screening`] sanctions index, plus
//! adds PEP and adverse-media coordination per FATF Recommendation 12.
//! - [`split`] — split-payments engine with sum / negativity /
//! currency / duplicate-destination validation.
//! - [`transfer`] — internal-ledger transfer between connected accounts.
//! - [`payout`] — schedule arithmetic, rolling-reserve withholding,
//! payout-instruction builder.
//! - [`liability`] — Platform / SubMerchant / Hybrid liability models
//! driving tax attribution, dispute pass-through, and PCI scope
//! inheritance.
//! - [`tax_reporting`] — IRS Form 1099-K and Form 1042-S generators,
//! driven by the IRS 2024-instructions threshold table (IRS Notice
//! 2024-85 phased step-down from $20k/200tx to $600 by 2026).
//! - [`tos`] — terms-of-service acceptance ledger with idempotent
//! replay semantics.
//!
//! ## What this crate does NOT do
//!
//! - **Rail submission.** Payout instructions hand off to `op-batch`
//! (ACH / SEPA / BACS / wire) or `op-rails-a2a` (FedNow / RTP); we
//! compute the instruction and stop.
//! - **Sanctions list ingestion.** That's `op-screening`'s job; we
//! compose its `Screener` with PEP and adverse-media variants.
//! - **Document verification.** Identity-document review is delegated
//! to a third-party provider (Persona, Onfido, Stripe Identity); we
//! carry vault references and a verification state.
//! - **State-by-state tax-license tracking.** Out of scope; integrate
//! a service like Avalara or TaxJar at the application layer.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
#![warn(clippy::pedantic)]
#![allow(clippy::module_name_repetitions)]
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::missing_panics_doc)]
#![allow(clippy::cast_precision_loss)]
#![allow(clippy::cast_possible_truncation)]
#![allow(clippy::cast_possible_wrap)]
#![allow(clippy::cast_sign_loss)]
#![allow(clippy::cast_lossless)]
#![allow(clippy::too_many_lines)]
#![allow(clippy::doc_markdown)]
#![allow(clippy::missing_const_for_fn)]
#![allow(clippy::needless_pass_by_value)]
#![allow(clippy::elidable_lifetime_names)]
#![allow(clippy::similar_names)]
#![allow(clippy::single_char_pattern)]
#![allow(clippy::unused_async)]
#![allow(clippy::option_if_let_else)]
#![allow(clippy::map_unwrap_or)]
#![allow(clippy::collapsible_if)]
#![allow(clippy::needless_borrows_for_generic_args)]
#![allow(clippy::manual_let_else)]
#![allow(clippy::uninlined_format_args)]
#![allow(clippy::doc_lazy_continuation)]
#![allow(clippy::inconsistent_digit_grouping)]
#![allow(clippy::match_same_arms)]
#![allow(clippy::derivable_impls)]
#![allow(clippy::float_cmp)]
#![allow(clippy::redundant_closure_for_method_calls)]
#![allow(clippy::redundant_clone)]
#![allow(clippy::single_match_else)]
#![allow(clippy::struct_field_names)]
#![allow(clippy::ref_option_ref)]
#![allow(clippy::default_trait_access)]
#![allow(clippy::option_option)]
#![allow(clippy::if_not_else)]
#![allow(clippy::unnested_or_patterns)]
#![allow(clippy::collapsible_match)]
#![allow(clippy::manual_assert)]
#![allow(clippy::useless_let_if_seq)]
#![allow(clippy::assigning_clones)]
#![allow(clippy::wildcard_imports)]
pub mod account;
pub mod error;
pub mod kyb;
pub mod liability;
pub mod onboarding;
pub mod payout;
pub mod screening;
pub mod split;
pub mod tax_reporting;
pub mod tos;
pub mod transfer;
pub use account::{
AccountId, AccountSettings, AccountType, Capability, ConnectedAccount,
};
pub use error::{Error, Result};
pub use kyb::{
Address, BeneficialOwner, BusinessProfile, BusinessStructure, CountryCode,
EncryptedField, GovernmentId, Person, RequirementId, Requirements, TaxId, validate_cdd,
};
pub use liability::{DisputeResponder, LiabilityModel, RailKind};
pub use onboarding::{
ExternalAccount, NativeProvider, OnboardingFlow, OnboardingProvider, OnboardingStatus,
OnboardingStep, StepPayload, StepResult,
};
pub use payout::{PayoutInstruction, PayoutMode, PayoutSchedule};
pub use screening::{
ConnectScreener, Decision, ScreenCoverage, ScreeningResult, annotate_pep_flags,
build_pep_index_from_names,
};
pub use split::{PaymentId, PaymentSplit, SplitLeg};
pub use tax_reporting::{
AnnualTransaction, Form1042S, Form1099K, Form1099KThresholds, State, build_1042s,
build_1099k,
};
pub use tos::{AcceptanceStore, RecordOutcome, TosAcceptance};
pub use transfer::{Ledger, LedgerEntry, TransferId, transfer};