File size: 5,085 Bytes
1295969
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
//! KYC/AML — FinCEN, OFAC SDN screening, W-9/W-8BEN, EU AMLD6.
//!
//! Persistence: LMDB via persist::LmdbStore.
//! Per-user auth: callers may only read/write their own KYC record.
use crate::AppState;
use axum::{
    extract::{Path, State},
    http::{HeaderMap, StatusCode},
    response::Json,
};
use serde::{Deserialize, Serialize};
use tracing::warn;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum KycTier {
    Tier0Unverified,
    Tier1Basic,
    Tier2Full,
    Suspended,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum TaxForm {
    W9,
    W8Ben,
    W8BenE,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum OfacStatus {
    Clear,
    PendingScreening,
    Flagged,
    Blocked,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KycRecord {
    pub user_id: String,
    pub tier: KycTier,
    pub legal_name: Option<String>,
    pub country_code: Option<String>,
    pub id_type: Option<String>,
    pub tax_form: Option<TaxForm>,
    pub tin_hash: Option<String>,
    pub ofac_status: OfacStatus,
    pub created_at: String,
    pub updated_at: String,
    pub payout_blocked: bool,
}

#[derive(Deserialize)]
pub struct KycSubmission {
    pub legal_name: String,
    pub country_code: String,
    pub id_type: String,
    pub tax_form: TaxForm,
    pub tin_hash: Option<String>,
}

pub struct KycStore {
    db: crate::persist::LmdbStore,
}

impl KycStore {
    pub fn open(path: &str) -> anyhow::Result<Self> {
        Ok(Self {
            db: crate::persist::LmdbStore::open(path, "kyc_records")?,
        })
    }

    pub fn get(&self, uid: &str) -> Option<KycRecord> {
        self.db.get(uid).ok().flatten()
    }

    pub fn upsert(&self, r: KycRecord) {
        if let Err(e) = self.db.put(&r.user_id, &r) {
            tracing::error!(err=%e, user=%r.user_id, "KYC persist error");
        }
    }

    pub fn payout_permitted(&self, uid: &str, amount_usd: f64) -> bool {
        match self.get(uid) {
            None => false,
            Some(r) => {
                if r.payout_blocked {
                    return false;
                }
                if r.ofac_status != OfacStatus::Clear {
                    return false;
                }
                if amount_usd > 3000.0 && r.tier != KycTier::Tier2Full {
                    return false;
                }
                r.tier != KycTier::Tier0Unverified
            }
        }
    }
}

// OFAC sanctioned countries (comprehensive programs, 2025)
const SANCTIONED: &[&str] = &["CU", "IR", "KP", "RU", "SY", "VE"];

async fn screen_ofac(name: &str, country: &str) -> OfacStatus {
    if SANCTIONED.contains(&country) {
        warn!(name=%name, country=%country, "OFAC: sanctioned country");
        return OfacStatus::Flagged;
    }
    // Production: call Refinitiv/ComplyAdvantage/LexisNexis SDN API
    OfacStatus::Clear
}

pub async fn submit_kyc(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(uid): Path<String>,
    Json(req): Json<KycSubmission>,
) -> Result<Json<serde_json::Value>, StatusCode> {
    // PER-USER AUTH: caller must own this uid
    let caller = crate::auth::extract_caller(&headers)?;
    if !caller.eq_ignore_ascii_case(&uid) {
        warn!(caller=%caller, uid=%uid, "KYC submit: caller != uid — forbidden");
        return Err(StatusCode::FORBIDDEN);
    }

    let ofac = screen_ofac(&req.legal_name, &req.country_code).await;
    let blocked = ofac == OfacStatus::Flagged || ofac == OfacStatus::Blocked;
    let tier = if blocked {
        KycTier::Suspended
    } else {
        KycTier::Tier1Basic
    };
    let now = chrono::Utc::now().to_rfc3339();
    state.kyc_db.upsert(KycRecord {
        user_id: uid.clone(),
        tier: tier.clone(),
        legal_name: Some(req.legal_name.clone()),
        country_code: Some(req.country_code.clone()),
        id_type: Some(req.id_type),
        tax_form: Some(req.tax_form),
        tin_hash: req.tin_hash,
        ofac_status: ofac.clone(),
        created_at: now.clone(),
        updated_at: now,
        payout_blocked: blocked,
    });
    state
        .audit_log
        .record(&format!(
            "KYC_SUBMIT user='{uid}' tier={tier:?} ofac={ofac:?}"
        ))
        .ok();
    if blocked {
        warn!(user=%uid, "KYC: payout blocked — OFAC flag");
    }
    Ok(Json(serde_json::json!({
        "user_id": uid, "tier": format!("{:?}", tier),
        "ofac_status": format!("{:?}", ofac), "payout_blocked": blocked,
    })))
}

pub async fn kyc_status(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(uid): Path<String>,
) -> Result<Json<KycRecord>, StatusCode> {
    // PER-USER AUTH: caller may only read their own record
    let caller = crate::auth::extract_caller(&headers)?;
    if !caller.eq_ignore_ascii_case(&uid) {
        warn!(caller=%caller, uid=%uid, "KYC status: caller != uid — forbidden");
        return Err(StatusCode::FORBIDDEN);
    }

    state
        .kyc_db
        .get(&uid)
        .map(Json)
        .ok_or(StatusCode::NOT_FOUND)
}