use std::sync::Arc;
use chrono::{DateTime, Duration, Utc};
use uuid::Uuid;
use crate::errors::AppError;
use crate::repositories::{
AccreditationDocumentEntity, AccreditationRepository, AccreditationSubmissionEntity,
UserRepository,
};
use crate::services::settings_service::SettingsService;
pub struct AccreditationStatusResponse {
pub status: String,
pub verified_at: Option<DateTime<Utc>>,
pub expires_at: Option<DateTime<Utc>>,
pub enforcement_mode: String,
}
pub struct SubmitAccreditationResponse {
pub submission_id: Uuid,
}
pub struct SubmitAccreditationData {
pub method: String,
pub income_type: Option<String>,
pub stated_amount_usd: Option<f64>,
pub crd_number: Option<String>,
pub license_type: Option<String>,
pub investment_commitment_usd: Option<f64>,
pub entity_type: Option<String>,
pub user_statement: Option<String>,
}
pub struct AccreditationService {
accreditation_repo: Arc<dyn AccreditationRepository>,
user_repo: Arc<dyn UserRepository>,
settings_service: Arc<SettingsService>,
}
impl AccreditationService {
pub fn new(
accreditation_repo: Arc<dyn AccreditationRepository>,
user_repo: Arc<dyn UserRepository>,
settings_service: Arc<SettingsService>,
) -> Self {
Self {
accreditation_repo,
user_repo,
settings_service,
}
}
pub async fn submit_verification(
&self,
user_id: Uuid,
data: SubmitAccreditationData,
) -> Result<SubmitAccreditationResponse, AppError> {
self.check_accreditation_enabled().await?;
let user = self
.user_repo
.find_by_id(user_id)
.await?
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
if user.accreditation_status == "approved" {
let still_valid = match user.accreditation_expires_at {
Some(exp) => exp > Utc::now(),
None => true,
};
if still_valid {
return Err(AppError::Validation("User is already accredited".into()));
}
}
if user.accreditation_status == "pending" {
return Err(AppError::Validation(
"An accreditation submission is already in progress".into(),
));
}
validate_method(&data.method)?;
validate_method_fields(&data)?;
let now = Utc::now();
let submission = AccreditationSubmissionEntity {
id: Uuid::new_v4(),
user_id,
method: data.method,
status: "pending".to_string(),
income_type: data.income_type,
stated_amount_usd: data.stated_amount_usd,
crd_number: data.crd_number,
license_type: data.license_type,
investment_commitment_usd: data.investment_commitment_usd,
entity_type: data.entity_type,
user_statement: data.user_statement,
reviewed_by: None,
reviewed_at: None,
reviewer_notes: None,
rejection_reason: None,
expires_at: None,
created_at: now,
updated_at: now,
};
let stored = self
.accreditation_repo
.create_submission(submission)
.await?;
self.user_repo
.set_accreditation_status(user_id, "pending", None, None)
.await?;
Ok(SubmitAccreditationResponse {
submission_id: stored.id,
})
}
pub async fn add_document(
&self,
user_id: Uuid,
submission_id: Uuid,
doc_type: String,
s3_key: String,
filename: Option<String>,
content_type: Option<String>,
size: Option<i64>,
) -> Result<AccreditationDocumentEntity, AppError> {
let submission = self
.accreditation_repo
.find_submission_by_id(submission_id)
.await?
.filter(|s| s.user_id == user_id)
.ok_or_else(|| AppError::NotFound("Submission not found".into()))?;
if submission.status != "pending" {
return Err(AppError::Validation(
"Cannot add documents to a non-pending submission".into(),
));
}
validate_doc_type(&doc_type)?;
let doc = AccreditationDocumentEntity {
id: Uuid::new_v4(),
submission_id,
document_type: doc_type,
s3_key,
original_filename: filename,
content_type,
file_size_bytes: size,
uploaded_at: Utc::now(),
};
self.accreditation_repo.add_document(doc).await
}
pub async fn get_status(&self, user_id: Uuid) -> Result<AccreditationStatusResponse, AppError> {
let user = self
.user_repo
.find_by_id(user_id)
.await?
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
let effective_status = compute_effective_accreditation_status(
&user.accreditation_status,
user.accreditation_expires_at,
);
let enforcement_mode = self
.settings_service
.get("accreditation_enforcement_mode")
.await?
.unwrap_or_else(|| "none".to_string());
Ok(AccreditationStatusResponse {
status: effective_status,
verified_at: user.accreditation_verified_at,
expires_at: user.accreditation_expires_at,
enforcement_mode,
})
}
pub async fn list_submissions(
&self,
user_id: Uuid,
limit: u32,
offset: u32,
) -> Result<Vec<AccreditationSubmissionEntity>, AppError> {
self.accreditation_repo
.list_submissions_by_user(user_id, limit, offset)
.await
}
pub async fn count_submissions(&self, user_id: Uuid) -> Result<u64, AppError> {
self.accreditation_repo
.count_submissions_by_user(user_id)
.await
}
pub async fn admin_list_pending(
&self,
limit: u32,
offset: u32,
) -> Result<(Vec<AccreditationSubmissionEntity>, u64), AppError> {
let submissions = self
.accreditation_repo
.list_pending_submissions(limit, offset)
.await?;
let total = self.accreditation_repo.count_pending_submissions().await?;
Ok((submissions, total))
}
pub async fn admin_get_submission(
&self,
submission_id: Uuid,
) -> Result<Option<AccreditationSubmissionEntity>, AppError> {
self.accreditation_repo
.find_submission_by_id(submission_id)
.await
}
pub async fn admin_list_documents(
&self,
submission_id: Uuid,
) -> Result<Vec<AccreditationDocumentEntity>, AppError> {
self.accreditation_repo
.list_documents_by_submission(submission_id)
.await
}
pub async fn admin_get_document(
&self,
doc_id: Uuid,
) -> Result<Option<AccreditationDocumentEntity>, AppError> {
self.accreditation_repo.find_document_by_id(doc_id).await
}
pub async fn admin_review(
&self,
submission_id: Uuid,
admin_id: Uuid,
approved: bool,
reviewer_notes: Option<String>,
rejection_reason: Option<String>,
custom_expiry_days: Option<u32>,
) -> Result<(), AppError> {
let submission = self
.accreditation_repo
.find_submission_by_id(submission_id)
.await?
.ok_or_else(|| AppError::NotFound("Submission not found".into()))?;
if submission.status != "pending" {
return Err(AppError::Validation("Submission is not pending".into()));
}
if approved {
let expires_at = self
.compute_expiry(&submission.method, custom_expiry_days)
.await?;
self.accreditation_repo
.update_submission_review(
submission_id,
"approved",
admin_id,
reviewer_notes.as_deref(),
None,
expires_at,
)
.await?;
self.user_repo
.set_accreditation_status(
submission.user_id,
"approved",
Some(Utc::now()),
expires_at,
)
.await?;
} else {
self.accreditation_repo
.update_submission_review(
submission_id,
"rejected",
admin_id,
reviewer_notes.as_deref(),
rejection_reason.as_deref(),
None,
)
.await?;
self.user_repo
.set_accreditation_status(submission.user_id, "rejected", None, None)
.await?;
}
Ok(())
}
pub async fn check_enforcement(&self, user_id: Uuid) -> Result<(), AppError> {
let mode = self
.settings_service
.get("accreditation_enforcement_mode")
.await?
.unwrap_or_else(|| "none".to_string());
if mode == "none" || mode == "optional" {
return Ok(());
}
let user = self
.user_repo
.find_by_id(user_id)
.await?
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
if user.accreditation_status != "approved" {
return Err(AppError::Forbidden("Accreditation required".into()));
}
if let Some(expires) = user.accreditation_expires_at {
if expires <= Utc::now() {
return Err(AppError::Forbidden("Accreditation has expired".into()));
}
}
Ok(())
}
}
impl AccreditationService {
async fn check_accreditation_enabled(&self) -> Result<(), AppError> {
let enabled = self
.settings_service
.get_bool("accreditation_enabled")
.await?
.unwrap_or(false);
if !enabled {
return Err(AppError::Validation(
"Accreditation verification is not enabled".into(),
));
}
Ok(())
}
async fn compute_expiry(
&self,
method: &str,
custom_expiry_days: Option<u32>,
) -> Result<Option<DateTime<Utc>>, AppError> {
let days = if let Some(custom) = custom_expiry_days {
custom as i64
} else {
let key = expiry_settings_key(method);
self.settings_service
.get(key)
.await?
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0)
};
Ok(if days > 0 {
Some(Utc::now() + Duration::days(days))
} else {
None
})
}
}
pub fn compute_effective_accreditation_status(
stored_status: &str,
expires_at: Option<DateTime<Utc>>,
) -> String {
if stored_status == "approved" {
if let Some(exp) = expires_at {
if exp <= Utc::now() {
return "expired".to_string();
}
}
}
stored_status.to_string()
}
pub fn validate_method(method: &str) -> Result<(), AppError> {
match method {
"income"
| "net_worth"
| "credential"
| "third_party_letter"
| "insider"
| "investment_threshold" => Ok(()),
_ => Err(AppError::Validation(format!(
"Unknown accreditation method: {method}"
))),
}
}
pub fn validate_doc_type(doc_type: &str) -> Result<(), AppError> {
match doc_type {
"tax_w2"
| "tax_1099"
| "tax_return"
| "pay_stub"
| "bank_statement"
| "brokerage_statement"
| "third_party_letter"
| "finra_license"
| "insider_certification"
| "subscription_agreement"
| "other" => Ok(()),
_ => Err(AppError::Validation(format!(
"Unknown document type: {doc_type}"
))),
}
}
fn expiry_settings_key(method: &str) -> &'static str {
match method {
"credential" => "accreditation_default_expiry_days_credential",
"third_party_letter" => "accreditation_default_expiry_days_letter",
_ => "accreditation_default_expiry_days_income",
}
}
fn validate_method_fields(data: &SubmitAccreditationData) -> Result<(), AppError> {
match data.method.as_str() {
"income" => {
require_field(data.income_type.as_deref(), "income_type")?;
require_f64(data.stated_amount_usd, "stated_amount_usd")?;
}
"net_worth" => {
require_f64(data.stated_amount_usd, "stated_amount_usd")?;
}
"credential" => {
require_field(data.crd_number.as_deref(), "crd_number")?;
let lt = require_field(data.license_type.as_deref(), "license_type")?;
match lt {
"series_7" | "series_65" | "series_82" => {}
other => {
return Err(AppError::Validation(format!(
"Unknown license_type: {other}"
)))
}
}
}
"third_party_letter" => {
}
"insider" => {
require_field(data.user_statement.as_deref(), "user_statement")?;
}
"investment_threshold" => {
require_f64(data.investment_commitment_usd, "investment_commitment_usd")?;
let et = require_field(data.entity_type.as_deref(), "entity_type")?;
match et {
"individual" | "entity" => {}
other => {
return Err(AppError::Validation(format!(
"Unknown entity_type: {other}"
)))
}
}
}
_ => {}
}
Ok(())
}
fn require_field<'a>(value: Option<&'a str>, name: &str) -> Result<&'a str, AppError> {
match value {
Some(v) if !v.is_empty() => Ok(v),
_ => Err(AppError::Validation(format!(
"Missing required field: {name}"
))),
}
}
fn require_f64(value: Option<f64>, name: &str) -> Result<f64, AppError> {
value.ok_or_else(|| AppError::Validation(format!("Missing required field: {name}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn approved_without_expiry_stays_approved() {
assert_eq!(
compute_effective_accreditation_status("approved", None),
"approved"
);
}
#[test]
fn approved_with_future_expiry_stays_approved() {
let future = Utc::now() + Duration::days(30);
assert_eq!(
compute_effective_accreditation_status("approved", Some(future)),
"approved"
);
}
#[test]
fn approved_with_past_expiry_becomes_expired() {
let past = Utc::now() - Duration::days(1);
assert_eq!(
compute_effective_accreditation_status("approved", Some(past)),
"expired"
);
}
#[test]
fn non_approved_statuses_are_unchanged() {
let past = Utc::now() - Duration::days(1);
for s in ["none", "pending", "rejected", "expired"] {
assert_eq!(
compute_effective_accreditation_status(s, Some(past)),
s,
"status '{s}' should not be modified"
);
}
}
#[test]
fn valid_methods_are_accepted() {
for m in [
"income",
"net_worth",
"credential",
"third_party_letter",
"insider",
"investment_threshold",
] {
assert!(validate_method(m).is_ok(), "expected '{m}' to be valid");
}
}
#[test]
fn invalid_method_is_rejected() {
assert!(validate_method("wire_transfer").is_err());
assert!(validate_method("").is_err());
assert!(validate_method("INCOME").is_err());
}
#[test]
fn valid_doc_types_are_accepted() {
for dt in [
"tax_w2",
"tax_1099",
"tax_return",
"pay_stub",
"bank_statement",
"brokerage_statement",
"third_party_letter",
"finra_license",
"insider_certification",
"subscription_agreement",
"other",
] {
assert!(validate_doc_type(dt).is_ok(), "expected '{dt}' to be valid");
}
}
#[test]
fn invalid_doc_type_is_rejected() {
assert!(validate_doc_type("selfie").is_err());
assert!(validate_doc_type("").is_err());
}
}