open-standards/openpay/crates/op-connect/src/account.rs
Transaction Science 3fe109d58f ingest batch 4: 9 crates · table-stakes completers across all three workspaces
OpenPay (3 crates, ~13k LOC, 175 tests)
  op-connect           sub-merchant onboarding + KYB + split-payments + payouts + 1099-K
  op-3ds2              3DS2.3 SCA processor (AReq/ARes/CReq/CRes/RReq/RRes + exemptions)
  op-tax               US/EU/UK/AU/CA/IN tax with Avalara/Stripe/CCH/Vertex/TaxJar adapters

Smart Byte (3 crates, ~7k LOC, 57 tests)
  smart-byte-libp2p    libp2p transport (kad/gossipsub/mdns/autonat/relay/dcutr/identify/ping/req_resp)
  smart-byte-crdt      LWW/GCounter/PnCounter/OrSet/LwwMap/RgaList/2P-Set + HLC + vector clock + Automerge/Yjs interop
  smart-byte-keri-witness  KERI key-rotation cycle + witness layer + watchers + recovery + delegation

EOC (3 crates, ~8k LOC, 108 tests)
  eoc-tools            function-calling across Anthropic/OpenAI/Google/Mistral/Cohere + parallel exec + sandbox
  eoc-multimodal       vision (CLIP/SigLIP/ColPali) + audio (Whisper/MMS) + video sampling + modality router
  eoc-spec-decode      vanilla + SpS-with-temp + lookahead speculative decoding + EAGLE/Medusa stubs

All three workspaces compile cleanly. Forbidden-unsafe, no unwrap outside tests.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 04:00:04 -04:00

222 lines
7.9 KiB
Rust

//! Connected-account domain types.
//!
//! A connected account is a sub-merchant on a platform's payments deployment.
//! The platform onboards, screens, and pays out to many of them; each
//! connected account carries its own KYB profile, beneficial-owner list,
//! capability set, and outstanding-requirement vector.
//!
//! The terminology (`Standard` / `Express` / `Custom`) mirrors Stripe Connect
//! by intention: it is the de-facto vocabulary platform engineers reach for,
//! so adopting it lowers migration cost for teams moving off an incumbent.
use std::collections::BTreeSet;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::kyb::{BeneficialOwner, BusinessProfile, Requirements};
/// Strongly-typed identifier for a connected account.
///
/// `acct_` prefix + UUID v4 simple form. Operators can override by
/// implementing [`OnboardingProvider::create_account`](crate::onboarding::OnboardingProvider::create_account)
/// to return any non-empty string.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct AccountId(pub String);
impl AccountId {
/// Mint a fresh account id in the native `acct_<uuidv4>` format.
#[must_use]
pub fn new() -> Self {
Self(format!("acct_{}", Uuid::new_v4().simple()))
}
/// Borrow the underlying string.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Default for AccountId {
fn default() -> Self {
Self::new()
}
}
/// Onboarding-flavor of the connected account.
///
/// Vocabulary follows Stripe Connect because that is the de-facto industry
/// shorthand. Behaviour:
///
/// - **Standard** — sub-merchant holds the relationship with the acquirer
/// directly, signs the acquirer's terms, sees the acquirer dashboard.
/// The platform routes payments and takes fees but is not the merchant
/// of record.
/// - **Express** — sub-merchant goes through a co-branded onboarding flow
/// on the platform's domain; the platform handles UX while the
/// acquirer holds the underlying terms.
/// - **Custom** — the platform owns the entire relationship and is the
/// merchant of record. Sub-merchant never sees an acquirer surface.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AccountType {
/// Sub-merchant is the merchant of record on the acquirer.
Standard,
/// Co-branded flow; acquirer holds terms, platform owns UX.
Express,
/// Platform is merchant of record end-to-end.
Custom,
}
/// Capabilities a connected account may be authorised for.
///
/// Each capability is independently granted: a sub-merchant can be
/// approved for `CardPayments` and `Payouts` while still being
/// pending on `AchPayments`. Capability state is reflected in
/// [`Requirements`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Capability {
/// Card-present and card-not-present acceptance (Visa / Mastercard / Amex / Discover).
CardPayments,
/// US ACH debits and credits (NACHA).
AchPayments,
/// Account-to-account real-time rails (FedNow, RTP, SEPA Instant, Pix, UPI).
A2aPayments,
/// Crypto-rail acceptance (BTC, ETH, stablecoins).
CryptoPayments,
/// Outbound payouts to the sub-merchant's bank account.
Payouts,
/// US Form 1099-K filing on the sub-merchant's behalf.
TaxReporting1099K,
/// US Form 1042-S filing on a non-US sub-merchant's behalf.
TaxReporting1042S,
}
/// Per-account settings the platform configures.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AccountSettings {
/// Statement descriptor that appears on cardholder statements.
pub statement_descriptor: Option<String>,
/// Short (≤10 char) descriptor used on legacy 22-char rails.
pub statement_descriptor_short: Option<String>,
/// If true, this account participates in instant-payouts where eligible.
pub instant_payouts_enabled: bool,
/// If true, refunds debit the platform's account first (else the sub-merchant's).
pub debit_negative_balances: bool,
}
impl Default for AccountSettings {
fn default() -> Self {
Self {
statement_descriptor: None,
statement_descriptor_short: None,
instant_payouts_enabled: false,
debit_negative_balances: false,
}
}
}
/// A fully-described connected account.
///
/// Immutable from the caller's perspective: mutation happens through
/// [`crate::onboarding::OnboardingFlow`] step submission, which produces
/// a fresh `ConnectedAccount` snapshot.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConnectedAccount {
/// Stable identifier.
pub id: AccountId,
/// Onboarding flavor (renamed `type` because that's a Rust keyword).
#[serde(rename = "type")]
pub type_: AccountType,
/// Capabilities currently active. Sorted for stable serialisation.
pub capabilities: BTreeSet<Capability>,
/// KYB profile (legal name, structure, MCC, etc.).
pub business: BusinessProfile,
/// Beneficial owners declared and (eventually) verified.
pub beneficial_owners: Vec<BeneficialOwner>,
/// Outstanding regulatory requirements (FinCEN CDD + AMLD5 driven).
pub requirements: Requirements,
/// Per-account settings.
pub settings: AccountSettings,
/// Creation timestamp.
pub created: DateTime<Utc>,
/// Last mutation timestamp.
pub last_updated: DateTime<Utc>,
}
impl ConnectedAccount {
/// Construct a fresh account in `BusinessInfo`-pending state.
#[must_use]
pub fn new(type_: AccountType, business: BusinessProfile) -> Self {
let now = Utc::now();
Self {
id: AccountId::new(),
type_,
capabilities: BTreeSet::new(),
business,
beneficial_owners: Vec::new(),
requirements: Requirements::default(),
settings: AccountSettings::default(),
created: now,
last_updated: now,
}
}
/// True if all `currently_due` and `past_due` requirements are clear.
///
/// `eventually_due` and `pending_verification` are tolerated for an
/// "active" account — they represent future obligations that have
/// not yet blocked capabilities.
#[must_use]
pub fn is_active(&self) -> bool {
self.requirements.currently_due.is_empty()
&& self.requirements.past_due.is_empty()
&& self.requirements.disabled_reason.is_none()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::kyb::{BusinessStructure, CountryCode};
use crate::kyb::{Address, BusinessProfile};
fn sample_profile() -> BusinessProfile {
BusinessProfile {
legal_name: "Acme Widgets LLC".into(),
trade_name: Some("Acme".into()),
structure: BusinessStructure::SingleMemberLlc,
tax_id: None,
mcc: 5734,
country: CountryCode("US".into()),
registered_address: Address {
line1: "1 Infinite Loop".into(),
line2: None,
city: "Cupertino".into(),
region: "CA".into(),
postal_code: "95014".into(),
country: CountryCode("US".into()),
},
support_email: None,
support_phone: None,
website: None,
}
}
#[test]
fn account_id_round_trip() {
let id = AccountId::new();
assert!(id.as_str().starts_with("acct_"));
}
#[test]
fn fresh_account_is_inactive() {
let a = ConnectedAccount::new(AccountType::Custom, sample_profile());
// Brand-new accounts have no past-due (we haven't enumerated any
// currently-due yet either) so `is_active()` is true only when
// requirements have been computed; on the bare struct it is true.
assert!(a.is_active());
assert!(a.capabilities.is_empty());
}
}