1use 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#[derive(Debug, Deserialize, IntoParams)]
24pub struct SettlementPayoutsQuery {
25 #[param(example = "0x1234567890123456789012345678901234567890")]
27 pub wallet: String,
28 #[param(example = 50)]
30 pub limit: Option<i64>,
31 #[param(example = 0)]
33 pub offset: Option<i64>,
34 #[param(example = "BTC-20260131-100000-C")]
36 pub symbol: Option<String>,
37 #[param(example = true)]
39 pub ledger_applied: Option<bool>,
40}
41
42#[derive(Debug, Serialize, ToSchema)]
44pub struct SettlementPayoutsResponse {
45 pub success: bool,
47 pub data: Vec<SettlementPayoutEntry>,
49 pub pagination: Pagination,
51 pub error: Option<String>,
53}
54
55#[derive(Debug, Serialize, ToSchema)]
57pub struct SettlementPayoutEntry {
58 pub id: i64,
60 pub wallet: String,
62 pub symbol: String,
64 pub expiry_ts: i64,
66 #[schema(value_type = String)]
68 pub position_size: Decimal,
69 #[schema(value_type = String)]
71 pub settlement_price: Decimal,
72 #[schema(value_type = String)]
74 pub settlement_value: Decimal,
75 #[schema(value_type = Option<String>)]
77 pub settlement_entry_price: Option<Decimal>,
78 #[schema(value_type = Option<String>)]
80 pub cost_basis: Option<Decimal>,
81 #[schema(value_type = Option<String>)]
83 pub net_pnl: Option<Decimal>,
84 pub ledger_applied: bool,
86 pub is_seen: bool,
88 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#[derive(Debug, Deserialize, ToSchema)]
114pub struct SettlementPayoutSeenRequest {
115 #[schema(value_type = String, example = "0x1234567890123456789012345678901234567890")]
117 pub wallet: WalletAddress,
118 pub ids: Vec<i64>,
121 pub nonce: u64,
123 pub signature: String,
125}
126
127#[derive(Debug, Serialize, ToSchema)]
129pub struct SettlementPayoutSeenMutationResponse {
130 pub success: bool,
132 pub requested: usize,
134 pub affected: usize,
136 pub error: Option<String>,
138}
139
140#[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#[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}