From 3e9a8504864c5cb4367ad49d1f3c6039891387c7 Mon Sep 17 00:00:00 2001 From: kieran Date: Sat, 8 Mar 2025 18:30:02 +0000 Subject: [PATCH] tmp --- src/api/model.rs | 48 ++++++++++++++++++------- src/api/routes.rs | 8 ++--- src/exchange.rs | 72 +++++++++++++++++++++++++++++++++++--- src/mocks.rs | 22 ++++++++---- src/provisioner/pricing.rs | 30 ++++++++++------ 5 files changed, 141 insertions(+), 39 deletions(-) diff --git a/src/api/model.rs b/src/api/model.rs index 907b680..a2d554a 100644 --- a/src/api/model.rs +++ b/src/api/model.rs @@ -1,7 +1,7 @@ -use crate::exchange::Currency; -use crate::provisioner::PricingEngine; +use crate::exchange::{alt_prices, Currency, CurrencyAmount, ExchangeRateService}; +use crate::provisioner::{PricingData, PricingEngine}; use crate::status::VmState; -use anyhow::{bail, Result}; +use anyhow::{anyhow, bail, Context, Result}; use chrono::{DateTime, Utc}; use ipnetwork::IpNetwork; use lnvps_db::{LNVpsDb, Vm, VmCostPlan, VmCustomTemplate, VmHost, VmHostRegion, VmTemplate}; @@ -136,6 +136,26 @@ pub struct ApiTemplatesResponse { pub custom_template: Option>, } +impl ApiTemplatesResponse { + pub async fn expand_pricing(&mut self, rates: &Arc) -> Result<()> { + let rates = rates.list_rates().await?; + + for mut template in &mut self.templates { + if let Some(list_price) = template.cost_plan.price.first() { + for alt_price in alt_prices( + &rates, + CurrencyAmount(list_price.currency, list_price.amount), + ) { + template.cost_plan.price.push(ApiPrice { + currency: alt_price.0, + amount: alt_price.1, + }); + } + } + } + Ok(()) + } +} #[derive(Serialize, Deserialize, JsonSchema)] pub struct ApiCustomTemplateParams { pub id: u64, @@ -189,9 +209,9 @@ impl From for VmCustomTemplate { } } -#[derive(Serialize, Deserialize, JsonSchema)] -pub struct ApiCustomPrice { - pub currency: String, +#[derive(Copy, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ApiPrice { + pub currency: Currency, pub amount: f32, } @@ -216,7 +236,7 @@ impl ApiVmTemplate { let template = db.get_vm_template(template_id).await?; let cost_plan = db.get_cost_plan(template.cost_plan_id).await?; let region = db.get_host_region(template.region_id).await?; - Ok(Self::from_standard_data(&template, &cost_plan, ®ion)) + Self::from_standard_data(&template, &cost_plan, ®ion) } pub async fn from_custom(db: &Arc, vm_id: u64, template_id: u64) -> Result { @@ -239,6 +259,7 @@ impl ApiVmTemplate { name: pricing.name, amount: price.total(), currency: price.currency, + other_price: vec![], // filled externally interval_amount: 1, interval_type: ApiVmCostPlanIntervalType::Month, }, @@ -263,8 +284,8 @@ impl ApiVmTemplate { template: &VmTemplate, cost_plan: &VmCostPlan, region: &VmHostRegion, - ) -> Self { - Self { + ) -> Result { + Ok(Self { id: template.id, name: template.name.clone(), created: template.created, @@ -278,7 +299,9 @@ impl ApiVmTemplate { id: cost_plan.id, name: cost_plan.name.clone(), amount: cost_plan.amount, - currency: cost_plan.currency.clone(), + currency: Currency::from_str(&cost_plan.currency) + .map_err(|_| anyhow!("Invalid currency: {}", &cost_plan.currency))?, + other_price: vec![], //filled externally interval_amount: cost_plan.interval_amount, interval_type: cost_plan.interval_type.clone().into(), }, @@ -286,7 +309,7 @@ impl ApiVmTemplate { id: region.id, name: region.name.clone(), }, - } + }) } } #[derive(Serialize, Deserialize, JsonSchema)] @@ -311,8 +334,9 @@ impl From for ApiVmCostPlanIntervalType { pub struct ApiVmCostPlan { pub id: u64, pub name: String, + pub currency: Currency, pub amount: f32, - pub currency: String, + pub other_price: Vec, pub interval_amount: u64, pub interval_type: ApiVmCostPlanIntervalType, } diff --git a/src/api/routes.rs b/src/api/routes.rs index 716b1a5..7c5d99d 100644 --- a/src/api/routes.rs +++ b/src/api/routes.rs @@ -1,5 +1,5 @@ use crate::api::model::{ - AccountPatchRequest, ApiCustomPrice, ApiCustomTemplateDiskParam, ApiCustomTemplateParams, + AccountPatchRequest, ApiPrice, ApiCustomTemplateDiskParam, ApiCustomTemplateParams, ApiCustomVmOrder, ApiCustomVmRequest, ApiTemplatesResponse, ApiUserSshKey, ApiVmHostRegion, ApiVmIpAssignment, ApiVmOsImage, ApiVmPayment, ApiVmStatus, ApiVmTemplate, CreateSshKey, CreateVmRequest, VMPatchRequest, @@ -301,7 +301,7 @@ async fn v1_list_vm_templates(db: &State>) -> ApiResult = @@ -373,12 +373,12 @@ async fn v1_list_vm_templates(db: &State>) -> ApiResult>, req: Json, -) -> ApiResult { +) -> ApiResult { // create a fake template from the request to generate the price let template: VmCustomTemplate = req.0.into(); let price = PricingEngine::get_custom_vm_cost_amount(db, 0, &template).await?; - ApiData::ok(ApiCustomPrice { + ApiData::ok(ApiPrice { currency: price.currency.clone(), amount: price.total(), }) diff --git a/src/exchange.rs b/src/exchange.rs index a46d545..e40887c 100644 --- a/src/exchange.rs +++ b/src/exchange.rs @@ -1,7 +1,8 @@ -use anyhow::{Error, Result}; +use anyhow::{anyhow, ensure, Context, Error, Result}; use lnvps_db::async_trait; use log::info; use rocket::serde::Deserialize; +use schemars::JsonSchema; use serde::Serialize; use std::collections::HashMap; use std::fmt::{Display, Formatter}; @@ -9,7 +10,7 @@ use std::str::FromStr; use std::sync::Arc; use tokio::sync::RwLock; -#[derive(Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Clone)] +#[derive(Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Clone, Copy, JsonSchema)] pub enum Currency { EUR, BTC, @@ -39,12 +40,12 @@ impl FromStr for Currency { } } -#[derive(Debug, PartialEq, Eq, Hash)] -pub struct Ticker(Currency, Currency); +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Ticker(pub Currency, pub Currency); impl Ticker { pub fn btc_rate(cur: &str) -> Result { - let to_cur: Currency = cur.parse().map_err(|_| Error::msg(""))?; + let to_cur: Currency = cur.parse().map_err(|_| anyhow!("Invalid currency"))?; Ok(Ticker(Currency::BTC, to_cur)) } } @@ -58,11 +59,43 @@ impl Display for Ticker { #[derive(Debug, PartialEq)] pub struct TickerRate(pub Ticker, pub f32); +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CurrencyAmount(pub Currency, pub f32); + +impl TickerRate { + pub fn can_convert(&self, currency: Currency) -> bool { + currency == self.0 .0 || currency == self.0 .1 + } + + /// Convert from the source currency into the target currency + pub fn convert(&self, source: CurrencyAmount) -> Result { + ensure!( + self.can_convert(source.0), + "Cant convert, currency doesnt match" + ); + if source.0 == self.0 .0 { + Ok(CurrencyAmount(self.0 .1, source.1 * self.1)) + } else { + Ok(CurrencyAmount(self.0 .0, source.1 / self.1)) + } + } +} + #[async_trait] pub trait ExchangeRateService: Send + Sync { async fn fetch_rates(&self) -> Result>; async fn set_rate(&self, ticker: Ticker, amount: f32); async fn get_rate(&self, ticker: Ticker) -> Option; + async fn list_rates(&self) -> Result>; +} + +/// Get alternative prices based on a source price +pub fn alt_prices(rates: &Vec, source: CurrencyAmount) -> Vec { + // TODO: return all alt prices by cross-converting all currencies + rates + .iter() + .filter_map(|r| r.convert(source).ok()) + .collect() } #[derive(Clone, Default)] @@ -100,6 +133,11 @@ impl ExchangeRateService for DefaultRateCache { let cache = self.cache.read().await; cache.get(&ticker).cloned() } + + async fn list_rates(&self) -> Result> { + let cache = self.cache.read().await; + Ok(cache.iter().map(|(k, v)| TickerRate(*k, *v)).collect()) + } } #[derive(Deserialize)] @@ -109,3 +147,27 @@ struct MempoolRates { #[serde(rename = "EUR")] pub eur: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + const RATE: f32 = 95_000.0; + #[test] + fn convert() { + let ticker = Ticker::btc_rate("EUR").unwrap(); + let f = TickerRate(ticker, RATE); + + assert_eq!( + f.convert(CurrencyAmount(Currency::EUR, 5.0)).unwrap(), + CurrencyAmount(Currency::BTC, 5.0 / RATE) + ); + assert_eq!( + f.convert(CurrencyAmount(Currency::BTC, 0.001)).unwrap(), + CurrencyAmount(Currency::EUR, RATE * 0.001) + ); + assert!(!f.can_convert(Currency::USD)); + assert!(f.can_convert(Currency::EUR)); + assert!(f.can_convert(Currency::BTC)); + } +} diff --git a/src/mocks.rs b/src/mocks.rs index 68516b9..7b4abff 100644 --- a/src/mocks.rs +++ b/src/mocks.rs @@ -80,7 +80,7 @@ impl MockDb { } } - pub fn mock_vm() ->Vm { + pub fn mock_vm() -> Vm { let template = Self::mock_template(); Vm { id: 1, @@ -861,13 +861,13 @@ impl DnsServer for MockDnsServer { } pub struct MockExchangeRate { - pub rate: Arc>, + pub rate: Arc>>, } impl MockExchangeRate { - pub fn new(rate: f32) -> Self { + pub fn new() -> Self { Self { - rate: Arc::new(Mutex::new(rate)), + rate: Arc::new(Mutex::new(Default::default())), } } } @@ -876,16 +876,24 @@ impl MockExchangeRate { impl ExchangeRateService for MockExchangeRate { async fn fetch_rates(&self) -> anyhow::Result> { let r = self.rate.lock().await; - Ok(vec![TickerRate(Ticker::btc_rate("EUR")?, *r)]) + Ok(r.iter().map(|(k, v)| TickerRate(k.clone(), *v)).collect()) } async fn set_rate(&self, ticker: Ticker, amount: f32) { let mut r = self.rate.lock().await; - *r = amount; + if let Some(v) = r.get_mut(&ticker) { + *v += amount; + } else { + r.insert(ticker, amount); + } } async fn get_rate(&self, ticker: Ticker) -> Option { let r = self.rate.lock().await; - Some(*r) + r.get(&ticker).cloned() + } + + async fn list_rates(&self) -> anyhow::Result> { + self.fetch_rates().await } } diff --git a/src/provisioner/pricing.rs b/src/provisioner/pricing.rs index c165a20..1e87e1e 100644 --- a/src/provisioner/pricing.rs +++ b/src/provisioner/pricing.rs @@ -1,5 +1,5 @@ -use crate::exchange::{ExchangeRateService, Ticker}; -use anyhow::{bail, Result}; +use crate::exchange::{Currency, ExchangeRateService, Ticker}; +use anyhow::{bail, Context, Result}; use chrono::{DateTime, Days, Months, TimeDelta, Utc}; use ipnetwork::IpNetwork; use lnvps_db::{LNVpsDb, Vm, VmCostPlan, VmCostPlanIntervalType, VmCustomTemplate, VmPayment}; @@ -86,8 +86,13 @@ impl PricingEngine { let ip4_cost = pricing.ip4_cost * v4s as f32; let ip6_cost = pricing.ip6_cost * v6s as f32; + let currency: Currency = if let Ok(p) = pricing.currency.parse() { + p + } else { + bail!("Invalid currency") + }; Ok(PricingData { - currency: pricing.currency, + currency, cpu_cost, memory_cost, ip6_cost, @@ -109,9 +114,7 @@ impl PricingEngine { // custom templates are always 1-month intervals let time_value = (vm.expires.add(Months::new(1)) - vm.expires).num_seconds() as u64; - let (cost_msats, rate) = self - .get_msats_amount(&price.currency, price.total()) - .await?; + let (cost_msats, rate) = self.get_msats_amount(price.currency, price.total()).await?; Ok(CostResult::New { msats: cost_msats, rate, @@ -120,8 +123,8 @@ impl PricingEngine { }) } - async fn get_msats_amount(&self, currency: &str, amount: f32) -> Result<(u64, f32)> { - let ticker = Ticker::btc_rate(¤cy)?; + async fn get_msats_amount(&self, currency: Currency, amount: f32) -> Result<(u64, f32)> { + let ticker = Ticker(Currency::BTC, currency); let rate = if let Some(r) = self.rates.get_rate(ticker).await { r } else { @@ -157,7 +160,10 @@ impl PricingEngine { let cost_plan = self.db.get_cost_plan(template.cost_plan_id).await?; let (cost_msats, rate) = self - .get_msats_amount(cost_plan.currency.as_str(), cost_plan.amount) + .get_msats_amount( + cost_plan.currency.parse().expect("Invalid currency"), + cost_plan.amount, + ) .await?; let time_value = Self::next_template_expire(&vm, &cost_plan); Ok(CostResult::New { @@ -188,7 +194,7 @@ pub enum CostResult { #[derive(Clone, Debug)] pub struct PricingData { - pub currency: String, + pub currency: Currency, pub cpu_cost: f32, pub memory_cost: f32, pub ip4_cost: f32, @@ -274,7 +280,9 @@ mod tests { #[tokio::test] async fn standard_pricing() -> Result<()> { let db = MockDb::default(); - let rates = Arc::new(MockExchangeRate::new(MOCK_RATE)); + let rates = Arc::new(MockExchangeRate::new()); + rates.set_rate(Ticker::btc_rate("EUR")?, MOCK_RATE).await; + // add basic vm { let mut v = db.vms.lock().await;