Skip to main content

hypercall_api/handlers/
settlement.rs

1//! Settlement payout REST API handlers.
2//!
3//! Provides endpoint for querying settlement payout history by wallet.
4
5use axum::{
6    extract::{Query, State},
7    http::StatusCode,
8    Json,
9};
10use hypercall_db::{AnalyticsReader, AnalyticsWriter};
11use rust_decimal::Decimal;
12use serde::{Deserialize, Serialize};
13use utoipa::{IntoParams, ToSchema};
14
15use super::AppState;
16use crate::error::ApiError;
17use crate::middleware::SignerContext;
18use crate::models::Pagination;
19use hypercall_settlement::{normalize_payout_ids, SettlementPayoutView};
20use hypercall_types::WalletAddress;
21
22/// Query parameters for settlement payouts endpoint.
23#[derive(Debug, Deserialize, IntoParams)]
24pub struct SettlementPayoutsQuery {
25    /// Wallet address to query.
26    #[param(example = "0x1234567890123456789012345678901234567890")]
27    pub wallet: String,
28    /// Maximum number of results to return (default: 50, max: 100).
29    #[param(example = 50)]
30    pub limit: Option<i64>,
31    /// Number of results to skip for pagination.
32    #[param(example = 0)]
33    pub offset: Option<i64>,
34    /// Optional symbol filter.
35    #[param(example = "BTC-20260131-100000-C")]
36    pub symbol: Option<String>,
37    /// Optional ledger applied filter.
38    #[param(example = true)]
39    pub ledger_applied: Option<bool>,
40}
41
42/// Settlement payouts response.
43#[derive(Debug, Serialize, ToSchema)]
44pub struct SettlementPayoutsResponse {
45    /// Whether the request was successful.
46    pub success: bool,
47    /// Settlement payout entries.
48    pub data: Vec<SettlementPayoutEntry>,
49    /// Pagination info.
50    pub pagination: Pagination,
51    /// Error message (if any).
52    pub error: Option<String>,
53}
54
55/// Settlement payout entry.
56#[derive(Debug, Serialize, ToSchema)]
57pub struct SettlementPayoutEntry {
58    /// Settlement payout row ID.
59    pub id: i64,
60    /// Wallet address.
61    pub wallet: String,
62    /// Option symbol.
63    pub symbol: String,
64    /// Expiry timestamp (seconds since epoch).
65    pub expiry_ts: i64,
66    /// Position size settled.
67    #[schema(value_type = String)]
68    pub position_size: Decimal,
69    /// Settlement price used.
70    #[schema(value_type = String)]
71    pub settlement_price: Decimal,
72    /// Settlement value credited/debited.
73    #[schema(value_type = String)]
74    pub settlement_value: Decimal,
75    /// User per-contract entry price at settlement time.
76    #[schema(value_type = Option<String>)]
77    pub settlement_entry_price: Option<Decimal>,
78    /// Position cost basis at settlement.
79    #[schema(value_type = Option<String>)]
80    pub cost_basis: Option<Decimal>,
81    /// Net settlement PnL (excluding fees).
82    #[schema(value_type = Option<String>)]
83    pub net_pnl: Option<Decimal>,
84    /// Whether this payout was applied to the ledger.
85    pub ledger_applied: bool,
86    /// Whether this payout has been marked seen by the wallet.
87    pub is_seen: bool,
88    /// Creation time in milliseconds since epoch.
89    pub created_at: Option<i64>,
90}
91
92impl From<SettlementPayoutView> for SettlementPayoutEntry {
93    fn from(view: SettlementPayoutView) -> Self {
94        Self {
95            id: view.id,
96            wallet: view.wallet,
97            symbol: view.symbol,
98            expiry_ts: view.expiry_ts,
99            position_size: view.position_size,
100            settlement_price: view.settlement_price,
101            settlement_value: view.settlement_value,
102            settlement_entry_price: view.settlement_entry_price,
103            cost_basis: view.cost_basis,
104            net_pnl: view.net_pnl,
105            ledger_applied: view.ledger_applied,
106            is_seen: view.is_seen,
107            created_at: view.created_at,
108        }
109    }
110}
111
112/// Request body for marking payouts as seen.
113#[derive(Debug, Deserialize, ToSchema)]
114pub struct SettlementPayoutSeenRequest {
115    /// Wallet performing the action.
116    #[schema(value_type = String, example = "0x1234567890123456789012345678901234567890")]
117    pub wallet: WalletAddress,
118    /// Settlement payout IDs to update.
119    /// Signature verification hashes IDs as a comma-separated string in request order.
120    pub ids: Vec<i64>,
121    /// Nonce for EIP-712 signature verification.
122    pub nonce: u64,
123    /// EIP-712 signature.
124    pub signature: String,
125}
126
127/// Response for marking payouts as seen.
128#[derive(Debug, Serialize, ToSchema)]
129pub struct SettlementPayoutSeenMutationResponse {
130    /// Whether the request succeeded.
131    pub success: bool,
132    /// Number of IDs requested in the payload.
133    pub requested: usize,
134    /// Number of rows affected in storage.
135    pub affected: usize,
136    /// Error message (if any).
137    pub error: Option<String>,
138}
139
140/// GET /settlement-payouts - Get settlement payout history for a wallet.
141#[utoipa::path(
142    get,
143    path = "/settlement-payouts",
144    params(SettlementPayoutsQuery),
145    responses(
146        (status = 200, description = "Settlement payouts retrieved", body = SettlementPayoutsResponse),
147        (status = 400, description = "Invalid wallet address"),
148        (status = 500, description = "Internal server error")
149    ),
150    security(("wallet_query" = [])),
151    tag = "Portfolio"
152)]
153pub async fn get_settlement_payouts(
154    State(state): State<AppState>,
155    Query(query): Query<SettlementPayoutsQuery>,
156) -> Result<Json<SettlementPayoutsResponse>, (StatusCode, Json<SettlementPayoutsResponse>)> {
157    let wallet: WalletAddress = match query.wallet.parse() {
158        Ok(w) => w,
159        Err(_) => {
160            return Err((
161                StatusCode::BAD_REQUEST,
162                Json(SettlementPayoutsResponse {
163                    success: false,
164                    data: vec![],
165                    pagination: Pagination {
166                        limit: 0,
167                        offset: 0,
168                        count: 0,
169                    },
170                    error: Some("Invalid wallet address".to_string()),
171                }),
172            ));
173        }
174    };
175
176    let limit = query.limit.unwrap_or(50).clamp(1, 100);
177    let offset = query.offset.unwrap_or(0).max(0);
178
179    let analytics: &dyn AnalyticsReader = state.db.as_ref();
180    match analytics
181        .get_settlement_payouts(
182            &wallet,
183            limit,
184            offset,
185            query.symbol.as_deref(),
186            query.ledger_applied,
187        )
188        .await
189    {
190        Ok((records, count)) => {
191            let payout_ids: Vec<i64> = records.iter().map(|record| record.id).collect();
192            let seen_ids = match analytics
193                .get_seen_settlement_payout_ids(&wallet, &payout_ids)
194                .await
195            {
196                Ok(ids) => ids,
197                Err(e) => {
198                    tracing::error!("Failed to get seen settlement payout IDs: {}", e);
199                    return Err((
200                        StatusCode::INTERNAL_SERVER_ERROR,
201                        Json(SettlementPayoutsResponse {
202                            success: false,
203                            data: vec![],
204                            pagination: Pagination {
205                                limit: limit as usize,
206                                offset: offset as usize,
207                                count: 0,
208                            },
209                            error: Some(format!("Failed to get settlement payouts: {}", e)),
210                        }),
211                    ));
212                }
213            };
214
215            let data: Vec<SettlementPayoutEntry> = records
216                .into_iter()
217                .map(|record| {
218                    SettlementPayoutEntry::from(SettlementPayoutView {
219                        id: record.id,
220                        wallet: record.wallet.to_string(),
221                        symbol: record.symbol,
222                        expiry_ts: record.expiry_ts,
223                        position_size: record.position_size,
224                        settlement_price: record.settlement_price,
225                        settlement_value: record.payout_amount,
226                        settlement_entry_price: record.settlement_entry_price,
227                        cost_basis: record.cost_basis,
228                        net_pnl: record.net_pnl,
229                        ledger_applied: record.ledger_applied,
230                        is_seen: seen_ids.contains(&record.id),
231                        created_at: record.created_at.map(|t| t.timestamp_millis()),
232                    })
233                })
234                .collect();
235
236            Ok(Json(SettlementPayoutsResponse {
237                success: true,
238                data,
239                pagination: Pagination {
240                    limit: limit as usize,
241                    offset: offset as usize,
242                    count: count as usize,
243                },
244                error: None,
245            }))
246        }
247        Err(e) => {
248            tracing::error!("Failed to get settlement payouts: {}", e);
249            Err((
250                StatusCode::INTERNAL_SERVER_ERROR,
251                Json(SettlementPayoutsResponse {
252                    success: false,
253                    data: vec![],
254                    pagination: Pagination {
255                        limit: limit as usize,
256                        offset: offset as usize,
257                        count: 0,
258                    },
259                    error: Some(format!("Failed to get settlement payouts: {}", e)),
260                }),
261            ))
262        }
263    }
264}
265
266/// POST /settlement-payouts/seen - Mark settlement payouts as seen for a wallet.
267#[utoipa::path(
268    post,
269    path = "/settlement-payouts/seen",
270    request_body = SettlementPayoutSeenRequest,
271    responses(
272        (status = 200, description = "Settlement payouts marked as seen", body = SettlementPayoutSeenMutationResponse),
273        (status = 400, description = "Invalid request payload"),
274        (status = 403, description = "Wallet mismatch"),
275        (status = 500, description = "Internal server error")
276    ),
277    security(("eip712_signature" = [])),
278    tag = "Portfolio"
279)]
280pub async fn mark_settlement_payouts_seen(
281    State(state): State<AppState>,
282    signer_ctx: SignerContext,
283    Json(request): Json<SettlementPayoutSeenRequest>,
284) -> Result<Json<SettlementPayoutSeenMutationResponse>, ApiError> {
285    if request.wallet != signer_ctx.wallet_address {
286        return Err(ApiError::forbidden(
287            "Wallet mismatch: signer does not match request wallet",
288        ));
289    }
290
291    let ids = normalize_payout_ids(&request.ids)
292        .map_err(|e| ApiError::bad_request(e.message().to_string()))?;
293    let requested = ids.len();
294
295    let analytics: &dyn AnalyticsWriter = state.db.as_ref();
296    let affected = analytics
297        .mark_settlement_payouts_seen(&request.wallet, &ids)
298        .await
299        .map_err(|e| {
300            tracing::error!("Failed to mark settlement payouts as seen: {}", e);
301            ApiError::internal_error("Failed to mark settlement payouts as seen")
302        })?;
303
304    Ok(Json(SettlementPayoutSeenMutationResponse {
305        success: true,
306        requested,
307        affected,
308        error: None,
309    }))
310}
311
312#[cfg(test)]
313mod tests {
314    use hypercall_settlement::normalize_payout_ids;
315
316    #[test]
317    fn normalize_payout_ids_preserves_order_and_duplicates() {
318        let normalized = normalize_payout_ids(&[5, 2, 2, 10, 1]).expect("valid ids");
319        assert_eq!(normalized, vec![5, 2, 2, 10, 1]);
320    }
321
322    #[test]
323    fn normalize_payout_ids_rejects_non_positive_values() {
324        assert!(normalize_payout_ids(&[1, 0, 2]).is_err());
325        assert!(normalize_payout_ids(&[1, -9, 2]).is_err());
326    }
327}