diff --git a/backend/src/api.rs b/backend/src/api.rs index e0e877929..d974244d1 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -106,6 +106,21 @@ pub struct AnchorQuery { pub page_size: Option, } +#[derive(Debug, Deserialize)] +pub struct YieldCalculateQuery { + pub amount: f64, + pub yield_rate_bps: Option, + pub elapsed_secs: u64, +} + +#[derive(Debug, Serialize)] +pub struct YieldCalculateResponse { + pub amount: f64, + pub yield_rate_bps: u32, + pub elapsed_secs: u64, + pub accrued_yield: f64, +} + /// Response for the /api/health endpoint. #[derive(Debug, Serialize)] pub struct HealthResponse { @@ -259,6 +274,7 @@ pub fn create_router(state: Arc) -> Router { .route("/api/health", get(health_check)) .route("/api/transactions/submit", post(submit_transaction)) .route("/api/admin/login", post(admin_login)) + .route("/api/yield/calculate", get(calculate_yield)) .route("/ws/kyc", get(ws_handler)); let router = Router::new() .merge(user_routes) @@ -2231,6 +2247,35 @@ async fn health_check(State(state): State>) -> impl IntoResponse { (status_code, Json(response)).into_response() } +async fn calculate_yield( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let yield_rate_bps = query.yield_rate_bps.unwrap_or(state.apy_config.rate_bps); + + if query.amount < 0.0 { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Amount must be non-negative"})), + ) + .into_response(); + } + + let accrued_yield = + yield_calculator::calculate_yield(query.amount, yield_rate_bps, query.elapsed_secs); + + ( + StatusCode::OK, + Json(YieldCalculateResponse { + amount: query.amount, + yield_rate_bps, + elapsed_secs: query.elapsed_secs, + accrued_yield, + }), + ) + .into_response() +} + // success, issues the JWT that `jwt_auth_middleware` expects for admin // routes. async fn admin_login( diff --git a/backend/tests/api_tests.rs b/backend/tests/api_tests.rs index 52ba0da78..a05039b77 100644 --- a/backend/tests/api_tests.rs +++ b/backend/tests/api_tests.rs @@ -669,3 +669,94 @@ async fn test_cors_origins() { ); } } + +#[tokio::test] +async fn test_calculate_yield_with_rate() { + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri("/api/yield/calculate?amount=10000&yield_rate_bps=500&elapsed_secs=31557600") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + assert_eq!(body_json["amount"], 10000.0); + assert_eq!(body_json["yield_rate_bps"], 500); + assert_eq!(body_json["elapsed_secs"], 31557600); + let accrued = body_json["accrued_yield"].as_f64().unwrap(); + assert!( + (accrued - 500.0).abs() < 0.01, + "expected ~500, got {accrued}" + ); +} + +#[tokio::test] +async fn test_calculate_yield_default_rate() { + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri("/api/yield/calculate?amount=2000&elapsed_secs=31557600") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + assert_eq!(body_json["yield_rate_bps"], 0); + assert_eq!(body_json["accrued_yield"], 0.0); +} + +#[tokio::test] +async fn test_calculate_yield_zero_elapsed() { + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri("/api/yield/calculate?amount=5000&yield_rate_bps=1000&elapsed_secs=0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + assert_eq!(body_json["accrued_yield"], 0.0); +} + +#[tokio::test] +async fn test_calculate_yield_invalid_amount() { + let app = setup_app(); + let response = app + .oneshot( + Request::builder() + .method(http::Method::GET) + .uri("/api/yield/calculate?amount=-100&yield_rate_bps=500&elapsed_secs=1000") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +}