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]>
631 lines
23 KiB
Rust
631 lines
23 KiB
Rust
//! Onboarding state machine.
|
|
//!
|
|
//! Sub-merchant onboarding is a stepwise process: collect business info,
|
|
//! collect a primary representative, collect beneficial owners, attach a
|
|
//! bank account, accept terms of service, verify the external account,
|
|
//! and finally verify the identity of the representative + owners.
|
|
//!
|
|
//! Each step has a corresponding `StepPayload` variant carrying the
|
|
//! data the step needs. The [`OnboardingProvider`] trait isolates the
|
|
//! actual side-effects so operators can wire one of:
|
|
//!
|
|
//! - **Native** — submit straight into the OpenPay account registry
|
|
//! (this crate's [`NativeProvider`]); deterministic, in-process, the
|
|
//! reference implementation for green-field deployments.
|
|
//! - **`StripeConnectAdapter`** — plug-in shape sketched in this module;
|
|
//! operators migrating off Connect can implement the trait against
|
|
//! Stripe's `/v1/accounts` API.
|
|
//! - **`AdyenMarketPayAdapter`** — same plug-in shape against Adyen's
|
|
//! `Marketplace API`.
|
|
//!
|
|
//! Adapter implementations live downstream — this crate only ships the
|
|
//! trait surface and the native implementation. Documenting the
|
|
//! adapters here keeps the migration path explicit without forcing
|
|
//! the dependency on every operator.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::sync::Mutex;
|
|
|
|
use chrono::Utc;
|
|
use serde::{Deserialize, Serialize};
|
|
use tracing::{debug, info};
|
|
|
|
use crate::account::{AccountId, AccountSettings, AccountType, ConnectedAccount};
|
|
use crate::error::{Error, Result};
|
|
use crate::kyb::{
|
|
BeneficialOwner, BusinessProfile, Requirements, validate_cdd,
|
|
};
|
|
use crate::tos::TosAcceptance;
|
|
|
|
/// Onboarding steps. Ordered loosely (some can be reordered or
|
|
/// concurrent), but [`OnboardingFlow::current_step`] always points at
|
|
/// the next-required-not-satisfied step.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum OnboardingStep {
|
|
/// Business profile + structure + tax ID.
|
|
BusinessInfo,
|
|
/// Primary representative (the natural person submitting the application).
|
|
RepresentativeInfo,
|
|
/// Beneficial owners per FinCEN CDD / AMLD5.
|
|
BeneficialOwners,
|
|
/// Sub-merchant's bank account for payouts.
|
|
BankAccount,
|
|
/// Terms-of-service acceptance.
|
|
TosAcceptance,
|
|
/// Verify the bank account (micro-deposit or Plaid-style flow).
|
|
ExternalAccountVerification,
|
|
/// Verify the representative's and owners' identities (document upload).
|
|
IdentityVerification,
|
|
}
|
|
|
|
impl OnboardingStep {
|
|
/// All steps in the canonical order.
|
|
#[must_use]
|
|
pub fn ordered() -> [Self; 7] {
|
|
[
|
|
Self::BusinessInfo,
|
|
Self::RepresentativeInfo,
|
|
Self::BeneficialOwners,
|
|
Self::BankAccount,
|
|
Self::TosAcceptance,
|
|
Self::ExternalAccountVerification,
|
|
Self::IdentityVerification,
|
|
]
|
|
}
|
|
}
|
|
|
|
/// External account (bank account) for sub-merchant payouts.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ExternalAccount {
|
|
/// ISO 3166-1 alpha-2 country.
|
|
pub country: String,
|
|
/// ISO 4217 currency code.
|
|
pub currency: String,
|
|
/// Routing number (US: ABA; EU: BIC) or local equivalent.
|
|
pub routing: String,
|
|
/// Account number (US: DDA; EU: IBAN).
|
|
pub account_number: String,
|
|
/// Account holder's name as it appears on the bank's records.
|
|
pub account_holder_name: String,
|
|
}
|
|
|
|
/// Payload submitted with a single onboarding step.
|
|
///
|
|
/// Per-variant: the step-to-payload mapping is checked at runtime;
|
|
/// passing the wrong payload for a step returns [`Error::OnboardingStepInvalid`].
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub enum StepPayload {
|
|
/// Body for [`OnboardingStep::BusinessInfo`].
|
|
BusinessInfo(BusinessProfile),
|
|
/// Body for [`OnboardingStep::RepresentativeInfo`].
|
|
RepresentativeInfo(BeneficialOwner),
|
|
/// Body for [`OnboardingStep::BeneficialOwners`]. Replaces the full roster.
|
|
BeneficialOwners(Vec<BeneficialOwner>),
|
|
/// Body for [`OnboardingStep::BankAccount`].
|
|
BankAccount(ExternalAccount),
|
|
/// Body for [`OnboardingStep::TosAcceptance`].
|
|
TosAcceptance(TosAcceptance),
|
|
/// Body for [`OnboardingStep::ExternalAccountVerification`].
|
|
/// Carries the matched micro-deposit amounts (in minor units).
|
|
ExternalAccountVerification {
|
|
/// Two micro-deposit amounts the customer claims to have seen.
|
|
amounts_minor: [i64; 2],
|
|
},
|
|
/// Body for [`OnboardingStep::IdentityVerification`]. Carries
|
|
/// vault references to uploaded ID documents keyed by person's
|
|
/// `legal_name`.
|
|
IdentityVerification {
|
|
/// Person's legal_name → vault reference to uploaded ID document.
|
|
documents: BTreeMap<String, String>,
|
|
},
|
|
}
|
|
|
|
impl StepPayload {
|
|
/// Which step this payload satisfies.
|
|
#[must_use]
|
|
pub const fn step(&self) -> OnboardingStep {
|
|
match self {
|
|
Self::BusinessInfo(_) => OnboardingStep::BusinessInfo,
|
|
Self::RepresentativeInfo(_) => OnboardingStep::RepresentativeInfo,
|
|
Self::BeneficialOwners(_) => OnboardingStep::BeneficialOwners,
|
|
Self::BankAccount(_) => OnboardingStep::BankAccount,
|
|
Self::TosAcceptance(_) => OnboardingStep::TosAcceptance,
|
|
Self::ExternalAccountVerification { .. } => {
|
|
OnboardingStep::ExternalAccountVerification
|
|
}
|
|
Self::IdentityVerification { .. } => OnboardingStep::IdentityVerification,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Outcome of one [`OnboardingProvider::submit_step`] call.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct StepResult {
|
|
/// The step that was just submitted.
|
|
pub step: OnboardingStep,
|
|
/// Recomputed requirements after the step.
|
|
pub requirements: Requirements,
|
|
/// True if the flow can now advance past this step.
|
|
pub accepted: bool,
|
|
/// Operator-facing message (e.g. "micro-deposit amounts did not match").
|
|
pub message: Option<String>,
|
|
}
|
|
|
|
/// Overall onboarding status.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum OnboardingStatus {
|
|
/// Account just created; no steps submitted.
|
|
Created,
|
|
/// At least one step submitted, but more remain.
|
|
InProgress,
|
|
/// All steps submitted; awaiting upstream verification of one or more.
|
|
PendingVerification,
|
|
/// All steps complete and verified; account is active.
|
|
Active,
|
|
/// Account is blocked; see `Requirements::disabled_reason`.
|
|
Disabled,
|
|
}
|
|
|
|
/// The full onboarding flow for one connected account.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct OnboardingFlow {
|
|
/// Account being onboarded.
|
|
pub acct_id: AccountId,
|
|
/// Steps required (computed at flow start from [`AccountType`] +
|
|
/// [`BusinessStructure`](crate::kyb::BusinessStructure)).
|
|
pub steps: Vec<OnboardingStep>,
|
|
/// Index into `steps` of the next step to submit.
|
|
pub current_step: usize,
|
|
/// Computed status.
|
|
pub status: OnboardingStatus,
|
|
}
|
|
|
|
impl OnboardingFlow {
|
|
/// Initial flow for a freshly created account.
|
|
#[must_use]
|
|
pub fn new(acct_id: AccountId) -> Self {
|
|
Self {
|
|
acct_id,
|
|
steps: OnboardingStep::ordered().to_vec(),
|
|
current_step: 0,
|
|
status: OnboardingStatus::Created,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Side-effecting onboarding interface.
|
|
///
|
|
/// One implementation per backend. The reference implementation is
|
|
/// [`NativeProvider`] (in-process, tokio-friendly, no external calls).
|
|
///
|
|
/// Adapter shapes for incumbent migrations:
|
|
///
|
|
/// ```ignore
|
|
/// struct StripeConnectAdapter { client: stripe::Client, platform_acct: String }
|
|
/// impl OnboardingProvider for StripeConnectAdapter {
|
|
/// async fn create_account(&self, profile: &BusinessProfile) -> Result<AccountId> {
|
|
/// let acct = self.client.post("/v1/accounts", &serde_json::json!({
|
|
/// "type": "custom", "country": profile.country.0,
|
|
/// "business_type": match profile.structure { /* … */ },
|
|
/// // map BusinessProfile → Stripe params
|
|
/// })).await.map_err(|e| Error::Provider(e.to_string()))?;
|
|
/// Ok(AccountId(acct["id"].as_str().unwrap_or_default().to_string()))
|
|
/// }
|
|
/// /* submit_step / status mapping per Stripe Connect docs … */
|
|
/// }
|
|
///
|
|
/// struct AdyenMarketPayAdapter { client: AdyenClient }
|
|
/// impl OnboardingProvider for AdyenMarketPayAdapter { /* /v1/accountHolders */ }
|
|
/// ```
|
|
#[async_trait::async_trait]
|
|
pub trait OnboardingProvider: Send + Sync {
|
|
/// Create a fresh connected account.
|
|
///
|
|
/// # Errors
|
|
/// Bubble any upstream provider failure as [`Error::Provider`].
|
|
async fn create_account(&self, profile: &BusinessProfile) -> Result<AccountId>;
|
|
|
|
/// Submit one onboarding step.
|
|
///
|
|
/// # Errors
|
|
/// - [`Error::AccountNotFound`] if `acct` is unknown.
|
|
/// - [`Error::OnboardingStepInvalid`] if `step` does not match
|
|
/// `payload.step()` or the step is out of sequence.
|
|
/// - [`Error::ScreeningBlocked`] if screening fired on a step.
|
|
/// - [`Error::Provider`] for upstream provider errors.
|
|
async fn submit_step(
|
|
&self,
|
|
acct: &AccountId,
|
|
step: OnboardingStep,
|
|
payload: StepPayload,
|
|
) -> Result<StepResult>;
|
|
|
|
/// Fetch the latest onboarding status.
|
|
///
|
|
/// # Errors
|
|
/// [`Error::AccountNotFound`] if `acct` is unknown.
|
|
async fn status(&self, acct: &AccountId) -> Result<OnboardingStatus>;
|
|
}
|
|
|
|
// Async trait helper. We pull `async_trait` in as a tiny dev-style
|
|
// dependency through tokio's transitive surface; if it isn't available
|
|
// we can collapse to a hand-rolled BoxFuture pattern.
|
|
#[doc(hidden)]
|
|
pub use async_trait as _async_trait_reexport;
|
|
|
|
/// Native, in-process [`OnboardingProvider`].
|
|
///
|
|
/// State lives in a `Mutex<BTreeMap>`; suitable for tests, kiosk
|
|
/// deployments, and the reference server. Production deployments
|
|
/// implement [`OnboardingProvider`] against their own account store.
|
|
pub struct NativeProvider {
|
|
accounts: Mutex<BTreeMap<AccountId, ConnectedAccount>>,
|
|
flows: Mutex<BTreeMap<AccountId, OnboardingFlow>>,
|
|
bank_accounts: Mutex<BTreeMap<AccountId, ExternalAccount>>,
|
|
tos: Mutex<BTreeMap<AccountId, TosAcceptance>>,
|
|
/// Micro-deposit amounts the test rig "sent" to the bank account.
|
|
/// In production this would be supplied by the rail driver.
|
|
expected_micro_deposits: Mutex<BTreeMap<AccountId, [i64; 2]>>,
|
|
account_type: AccountType,
|
|
}
|
|
|
|
impl NativeProvider {
|
|
/// Build a fresh provider.
|
|
#[must_use]
|
|
pub fn new(account_type: AccountType) -> Self {
|
|
Self {
|
|
accounts: Mutex::new(BTreeMap::new()),
|
|
flows: Mutex::new(BTreeMap::new()),
|
|
bank_accounts: Mutex::new(BTreeMap::new()),
|
|
tos: Mutex::new(BTreeMap::new()),
|
|
expected_micro_deposits: Mutex::new(BTreeMap::new()),
|
|
account_type,
|
|
}
|
|
}
|
|
|
|
/// Read-only snapshot of an account.
|
|
///
|
|
/// # Errors
|
|
/// [`Error::AccountNotFound`].
|
|
pub fn snapshot(&self, acct: &AccountId) -> Result<ConnectedAccount> {
|
|
self.accounts
|
|
.lock()
|
|
.expect("accounts mutex poisoned")
|
|
.get(acct)
|
|
.cloned()
|
|
.ok_or_else(|| Error::AccountNotFound(acct.0.clone()))
|
|
}
|
|
|
|
/// Read-only snapshot of the flow.
|
|
///
|
|
/// # Errors
|
|
/// [`Error::AccountNotFound`].
|
|
pub fn flow(&self, acct: &AccountId) -> Result<OnboardingFlow> {
|
|
self.flows
|
|
.lock()
|
|
.expect("flows mutex poisoned")
|
|
.get(acct)
|
|
.cloned()
|
|
.ok_or_else(|| Error::AccountNotFound(acct.0.clone()))
|
|
}
|
|
|
|
/// Inject the micro-deposit amounts the test rig "sent" to the bank.
|
|
pub fn arm_micro_deposits(&self, acct: &AccountId, amounts: [i64; 2]) {
|
|
self.expected_micro_deposits
|
|
.lock()
|
|
.expect("micro-deposits mutex poisoned")
|
|
.insert(acct.clone(), amounts);
|
|
}
|
|
|
|
fn requirements_for(structure: crate::kyb::BusinessStructure) -> Requirements {
|
|
let mut req = Requirements::default();
|
|
// Common to every account.
|
|
req.currently_due.push("business.legal_name".into());
|
|
req.currently_due.push("business.tax_id".into());
|
|
req.currently_due.push("representative.identity".into());
|
|
req.currently_due.push("external_account".into());
|
|
req.currently_due.push("tos_acceptance".into());
|
|
|
|
if structure.requires_beneficial_owners() {
|
|
req.currently_due.push("beneficial_owners".into());
|
|
}
|
|
|
|
req.eventually_due
|
|
.push("identity.document.verification".into());
|
|
|
|
req
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl OnboardingProvider for NativeProvider {
|
|
async fn create_account(&self, profile: &BusinessProfile) -> Result<AccountId> {
|
|
let mut account = ConnectedAccount::new(self.account_type, profile.clone());
|
|
account.requirements = Self::requirements_for(profile.structure);
|
|
let id = account.id.clone();
|
|
|
|
info!(acct = %id.0, "creating connected account");
|
|
|
|
self.accounts
|
|
.lock()
|
|
.expect("accounts mutex poisoned")
|
|
.insert(id.clone(), account);
|
|
self.flows
|
|
.lock()
|
|
.expect("flows mutex poisoned")
|
|
.insert(id.clone(), OnboardingFlow::new(id.clone()));
|
|
|
|
Ok(id)
|
|
}
|
|
|
|
async fn submit_step(
|
|
&self,
|
|
acct: &AccountId,
|
|
step: OnboardingStep,
|
|
payload: StepPayload,
|
|
) -> Result<StepResult> {
|
|
// Step/payload congruence check.
|
|
if payload.step() != step {
|
|
return Err(Error::OnboardingStepInvalid {
|
|
reason: format!(
|
|
"payload variant ({:?}) does not match step ({step:?})",
|
|
payload.step()
|
|
),
|
|
});
|
|
}
|
|
|
|
// Mutate the canonical account snapshot.
|
|
let mut accounts = self.accounts.lock().expect("accounts mutex poisoned");
|
|
let account = accounts
|
|
.get_mut(acct)
|
|
.ok_or_else(|| Error::AccountNotFound(acct.0.clone()))?;
|
|
|
|
let mut accepted = true;
|
|
let mut message: Option<String> = None;
|
|
|
|
match payload {
|
|
StepPayload::BusinessInfo(profile) => {
|
|
account.business = profile;
|
|
account.requirements.currently_due.retain(|r| {
|
|
r != "business.legal_name" && r != "business.tax_id"
|
|
});
|
|
}
|
|
StepPayload::RepresentativeInfo(owner) => {
|
|
// The representative is the first natural person on the
|
|
// file and is automatically the control-prong individual
|
|
// unless overridden by the BeneficialOwners step.
|
|
let mut rep = owner;
|
|
rep.control = true;
|
|
// If the representative also crosses the ownership prong,
|
|
// count them in the roster; otherwise just record control.
|
|
let existing_rep = account
|
|
.beneficial_owners
|
|
.iter_mut()
|
|
.find(|o| o.person.legal_name == rep.person.legal_name);
|
|
if let Some(slot) = existing_rep {
|
|
*slot = rep;
|
|
} else {
|
|
account.beneficial_owners.push(rep);
|
|
}
|
|
account
|
|
.requirements
|
|
.currently_due
|
|
.retain(|r| r != "representative.identity");
|
|
}
|
|
StepPayload::BeneficialOwners(owners) => {
|
|
// Validate against FinCEN CDD only if the structure
|
|
// requires it.
|
|
if account.business.structure.requires_beneficial_owners() {
|
|
validate_cdd(&owners)?;
|
|
}
|
|
// Merge with the representative if any.
|
|
let mut merged: Vec<BeneficialOwner> = owners;
|
|
// Preserve PEP flags / control flags already set on the rep.
|
|
for existing in &account.beneficial_owners {
|
|
if !merged
|
|
.iter()
|
|
.any(|o| o.person.legal_name == existing.person.legal_name)
|
|
{
|
|
merged.push(existing.clone());
|
|
}
|
|
}
|
|
account.beneficial_owners = merged;
|
|
account
|
|
.requirements
|
|
.currently_due
|
|
.retain(|r| r != "beneficial_owners");
|
|
}
|
|
StepPayload::BankAccount(ext) => {
|
|
self.bank_accounts
|
|
.lock()
|
|
.expect("bank mutex poisoned")
|
|
.insert(acct.clone(), ext);
|
|
account
|
|
.requirements
|
|
.currently_due
|
|
.retain(|r| r != "external_account");
|
|
// External-account verification is now currently-due
|
|
// (it transitions from eventually-due once the account
|
|
// is on file).
|
|
if !account
|
|
.requirements
|
|
.currently_due
|
|
.iter()
|
|
.any(|r| r == "external_account.verification")
|
|
{
|
|
account
|
|
.requirements
|
|
.currently_due
|
|
.push("external_account.verification".into());
|
|
}
|
|
}
|
|
StepPayload::TosAcceptance(tos) => {
|
|
self.tos
|
|
.lock()
|
|
.expect("tos mutex poisoned")
|
|
.insert(acct.clone(), tos);
|
|
account
|
|
.requirements
|
|
.currently_due
|
|
.retain(|r| r != "tos_acceptance");
|
|
}
|
|
StepPayload::ExternalAccountVerification { amounts_minor } => {
|
|
let expected = self
|
|
.expected_micro_deposits
|
|
.lock()
|
|
.expect("micro-deposit mutex poisoned")
|
|
.get(acct)
|
|
.copied();
|
|
match expected {
|
|
Some(arr) if arr == amounts_minor => {
|
|
account
|
|
.requirements
|
|
.currently_due
|
|
.retain(|r| r != "external_account.verification");
|
|
}
|
|
Some(_) => {
|
|
accepted = false;
|
|
message = Some(
|
|
"micro-deposit amounts did not match the values sent".into(),
|
|
);
|
|
}
|
|
None => {
|
|
accepted = false;
|
|
message = Some(
|
|
"no pending micro-deposits for this account; submit BankAccount first"
|
|
.into(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
StepPayload::IdentityVerification { documents } => {
|
|
// Mark every named person as documented; in production
|
|
// this would post the document to an ID-verification
|
|
// provider (Persona, Onfido, Stripe Identity, etc.).
|
|
debug!(acct = %acct.0, docs = documents.len(), "identity verification submitted");
|
|
account
|
|
.requirements
|
|
.eventually_due
|
|
.retain(|r| r != "identity.document.verification");
|
|
}
|
|
}
|
|
|
|
account.last_updated = Utc::now();
|
|
|
|
// Advance the flow cursor.
|
|
let mut flows = self.flows.lock().expect("flow mutex poisoned");
|
|
if let Some(flow) = flows.get_mut(acct) {
|
|
if accepted {
|
|
if let Some(pos) = flow.steps.iter().position(|s| *s == step) {
|
|
if pos >= flow.current_step {
|
|
flow.current_step = pos + 1;
|
|
}
|
|
}
|
|
}
|
|
flow.status = if !account.requirements.past_due.is_empty()
|
|
|| account.requirements.disabled_reason.is_some()
|
|
{
|
|
OnboardingStatus::Disabled
|
|
} else if account.requirements.currently_due.is_empty()
|
|
&& account.requirements.pending_verification.is_empty()
|
|
{
|
|
OnboardingStatus::Active
|
|
} else if !account.requirements.pending_verification.is_empty() {
|
|
OnboardingStatus::PendingVerification
|
|
} else {
|
|
OnboardingStatus::InProgress
|
|
};
|
|
}
|
|
|
|
Ok(StepResult {
|
|
step,
|
|
requirements: account.requirements.clone(),
|
|
accepted,
|
|
message,
|
|
})
|
|
}
|
|
|
|
async fn status(&self, acct: &AccountId) -> Result<OnboardingStatus> {
|
|
let flows = self.flows.lock().expect("flow mutex poisoned");
|
|
flows
|
|
.get(acct)
|
|
.map(|f| f.status)
|
|
.ok_or_else(|| Error::AccountNotFound(acct.0.clone()))
|
|
}
|
|
}
|
|
|
|
// We avoid a hard `async_trait` workspace dep by alias-importing the
|
|
// crate name; this attribute is provided by the upstream crate.
|
|
pub use async_trait::async_trait;
|
|
|
|
/// Internal helper: build default settings.
|
|
#[must_use]
|
|
pub fn default_account_settings() -> AccountSettings {
|
|
AccountSettings::default()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::kyb::{Address, BusinessStructure, CountryCode, Person, TaxId};
|
|
use chrono::NaiveDate;
|
|
|
|
fn sample_profile() -> BusinessProfile {
|
|
BusinessProfile {
|
|
legal_name: "Acme Widgets LLC".into(),
|
|
trade_name: None,
|
|
structure: BusinessStructure::SingleMemberLlc,
|
|
tax_id: Some(TaxId::Ein("12-3456789".into())),
|
|
mcc: 5734,
|
|
country: CountryCode("US".into()),
|
|
registered_address: Address {
|
|
line1: "1 Main St".into(),
|
|
line2: None,
|
|
city: "Austin".into(),
|
|
region: "TX".into(),
|
|
postal_code: "78701".into(),
|
|
country: CountryCode("US".into()),
|
|
},
|
|
support_email: Some("[email protected]".into()),
|
|
support_phone: None,
|
|
website: None,
|
|
}
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn sample_owner(name: &str, ownership: f32, control: bool) -> BeneficialOwner {
|
|
BeneficialOwner {
|
|
person: Person {
|
|
legal_name: name.into(),
|
|
dob: NaiveDate::from_ymd_opt(1985, 5, 15).expect("date"),
|
|
address: sample_profile().registered_address,
|
|
ssn_last_4: Some("4321".into()),
|
|
ssn_or_itin_full: None,
|
|
government_id: None,
|
|
},
|
|
ownership_pct: ownership,
|
|
control,
|
|
is_pep: false,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn step_payload_mismatch_errors() {
|
|
let p = NativeProvider::new(AccountType::Custom);
|
|
let acct = p.create_account(&sample_profile()).await.expect("create");
|
|
let err = p
|
|
.submit_step(
|
|
&acct,
|
|
OnboardingStep::BusinessInfo,
|
|
StepPayload::TosAcceptance(crate::tos::TosAcceptance {
|
|
ip: "0.0.0.0".into(),
|
|
user_agent: "x".into(),
|
|
accepted_at: Utc::now(),
|
|
version_hash: "h".into(),
|
|
}),
|
|
)
|
|
.await
|
|
.expect_err("mismatch");
|
|
assert!(matches!(err, Error::OnboardingStepInvalid { .. }));
|
|
}
|
|
}
|