This commit is contained in:
2025-03-08 18:30:02 +00:00
parent 8c3756e3e8
commit 3e9a850486
5 changed files with 141 additions and 39 deletions

View File

@ -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<Vec<ApiCustomTemplateParams>>,
}
impl ApiTemplatesResponse {
pub async fn expand_pricing(&mut self, rates: &Arc<dyn ExchangeRateService>) -> 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<ApiCustomVmRequest> 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, &region))
Self::from_standard_data(&template, &cost_plan, &region)
}
pub async fn from_custom(db: &Arc<dyn LNVpsDb>, vm_id: u64, template_id: u64) -> Result<Self> {
@ -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<Self> {
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<lnvps_db::VmCostPlanIntervalType> for ApiVmCostPlanIntervalType {
pub struct ApiVmCostPlan {
pub id: u64,
pub name: String,
pub currency: Currency,
pub amount: f32,
pub currency: String,
pub other_price: Vec<ApiPrice>,
pub interval_amount: u64,
pub interval_type: ApiVmCostPlanIntervalType,
}

View File

@ -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<Arc<dyn LNVpsDb>>) -> ApiResult<ApiTemp
.filter_map(|i| {
let cp = cost_plans.get(&i.cost_plan_id)?;
let hr = regions.get(&i.region_id)?;
Some(ApiVmTemplate::from_standard_data(i, cp, hr))
ApiVmTemplate::from_standard_data(i, cp, hr).ok()
})
.collect();
let custom_templates: Vec<VmCustomPricing> =
@ -373,12 +373,12 @@ async fn v1_list_vm_templates(db: &State<Arc<dyn LNVpsDb>>) -> ApiResult<ApiTemp
async fn v1_custom_template_calc(
db: &State<Arc<dyn LNVpsDb>>,
req: Json<ApiCustomVmRequest>,
) -> ApiResult<ApiCustomPrice> {
) -> ApiResult<ApiPrice> {
// 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(),
})

View File

@ -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<Self> {
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<CurrencyAmount> {
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<Vec<TickerRate>>;
async fn set_rate(&self, ticker: Ticker, amount: f32);
async fn get_rate(&self, ticker: Ticker) -> Option<f32>;
async fn list_rates(&self) -> Result<Vec<TickerRate>>;
}
/// Get alternative prices based on a source price
pub fn alt_prices(rates: &Vec<TickerRate>, source: CurrencyAmount) -> Vec<CurrencyAmount> {
// 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<Vec<TickerRate>> {
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<f32>,
}
#[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));
}
}

View File

@ -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<Mutex<f32>>,
pub rate: Arc<Mutex<HashMap<Ticker, f32>>>,
}
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<Vec<TickerRate>> {
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<f32> {
let r = self.rate.lock().await;
Some(*r)
r.get(&ticker).cloned()
}
async fn list_rates(&self) -> anyhow::Result<Vec<TickerRate>> {
self.fetch_rates().await
}
}

View File

@ -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(&currency)?;
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;