closes https://github.com/LNVPS/api/issues/31
This commit is contained in:
@ -56,6 +56,7 @@ ssh-key = "0.6.7"
|
||||
lettre = { version = "0.11.10", features = ["tokio1-native-tls"] }
|
||||
ws = { package = "rocket_ws", version = "0.1.1" }
|
||||
native-tls = "0.2.12"
|
||||
lnurl-rs = { version = "0.9.0", default-features = false }
|
||||
|
||||
futures = "0.3.31"
|
||||
isocountry = "0.3.2"
|
||||
|
@ -149,7 +149,7 @@ impl ApiTemplatesResponse {
|
||||
CurrencyAmount::from_f32(template.cost_plan.currency, template.cost_plan.amount);
|
||||
for alt_price in alt_prices(&rates, list_price) {
|
||||
template.cost_plan.other_price.push(ApiPrice {
|
||||
currency: alt_price.0,
|
||||
currency: alt_price.currency(),
|
||||
amount: alt_price.value_f32(),
|
||||
});
|
||||
}
|
||||
@ -255,7 +255,7 @@ pub struct ApiPrice {
|
||||
impl From<CurrencyAmount> for ApiPrice {
|
||||
fn from(value: CurrencyAmount) -> Self {
|
||||
Self {
|
||||
currency: value.0,
|
||||
currency: value.currency(),
|
||||
amount: value.value_f32(),
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ use crate::api::model::{
|
||||
ApiVmIpAssignment, ApiVmOsImage, ApiVmPayment, ApiVmStatus, ApiVmTemplate, CreateSshKey,
|
||||
CreateVmRequest, VMPatchRequest,
|
||||
};
|
||||
use crate::exchange::{Currency, ExchangeRateService};
|
||||
use crate::exchange::{Currency, CurrencyAmount, ExchangeRateService};
|
||||
use crate::host::{get_host_client, FullVmInfo, TimeSeries, TimeSeriesData};
|
||||
use crate::nip98::Nip98Auth;
|
||||
use crate::provisioner::{HostCapacityService, LNVpsProvisioner, PricingEngine};
|
||||
@ -15,11 +15,14 @@ use anyhow::{bail, Result};
|
||||
use futures::future::join_all;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use isocountry::CountryCode;
|
||||
use lnurl::pay::{LnURLPayInvoice, PayResponse};
|
||||
use lnurl::Tag;
|
||||
use lnvps_db::{
|
||||
IpRange, LNVpsDb, PaymentMethod, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate,
|
||||
};
|
||||
use log::{error, info};
|
||||
use nostr::util::hex;
|
||||
use nostr::Url;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::{get, patch, post, routes, Responder, Route, State};
|
||||
use rocket_okapi::gen::OpenApiGenerator;
|
||||
@ -64,7 +67,11 @@ pub fn routes() -> Vec<Route> {
|
||||
api_routes.append(&mut super::nostr_domain::routes());
|
||||
routes.append(&mut api_routes);
|
||||
|
||||
routes.append(&mut routes![v1_terminal_proxy]);
|
||||
routes.append(&mut routes![
|
||||
v1_terminal_proxy,
|
||||
v1_lnurlp,
|
||||
v1_renew_vm_lnurlp
|
||||
]);
|
||||
|
||||
routes
|
||||
}
|
||||
@ -540,6 +547,57 @@ async fn v1_renew_vm(
|
||||
ApiData::ok(rsp.into())
|
||||
}
|
||||
|
||||
/// Extend a VM by LNURL payment
|
||||
#[get("/api/v1/vm/<id>/renew-lnurlp?<amount>")]
|
||||
async fn v1_renew_vm_lnurlp(
|
||||
provisioner: &State<Arc<LNVpsProvisioner>>,
|
||||
id: u64,
|
||||
amount: u64,
|
||||
) -> Result<Json<LnURLPayInvoice>, &'static str> {
|
||||
if amount < 1000 {
|
||||
return Err("Amount must be greater than 1000");
|
||||
}
|
||||
|
||||
let rsp = provisioner
|
||||
.renew_amount(
|
||||
id,
|
||||
CurrencyAmount::millisats(amount),
|
||||
PaymentMethod::Lightning,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "Error generating invoice")?;
|
||||
|
||||
// external_data is pr for lightning payment method
|
||||
Ok(Json(LnURLPayInvoice::new(rsp.external_data)))
|
||||
}
|
||||
|
||||
/// LNURL ad-hoc extend vm
|
||||
#[get("/.well-known/lnurlp/<id>")]
|
||||
async fn v1_lnurlp(
|
||||
db: &State<Arc<dyn LNVpsDb>>,
|
||||
settings: &State<Settings>,
|
||||
id: u64,
|
||||
) -> Result<Json<PayResponse>, &'static str> {
|
||||
db.get_vm(id).await.map_err(|_e| "VM not found")?;
|
||||
|
||||
let meta = vec![vec!["text/plain".to_string(), format!("Extend VM {}", id)]];
|
||||
let rsp = PayResponse {
|
||||
callback: Url::parse(&settings.public_url)
|
||||
.map_err(|_| "Invalid public url")?
|
||||
.join(&format!("/api/v1/vm/{}/renew-lnurlp", id))
|
||||
.map_err(|_| "Could not get callback url")?
|
||||
.to_string(),
|
||||
max_sendable: 1_000_000_000,
|
||||
min_sendable: 1_000, // TODO: calc min by using 1s extend time
|
||||
tag: Tag::PayRequest,
|
||||
metadata: serde_json::to_string(&meta).map_err(|_e| "Failed to serialize metadata")?,
|
||||
comment_allowed: None,
|
||||
allows_nostr: None,
|
||||
nostr_pubkey: None,
|
||||
};
|
||||
Ok(Json(rsp))
|
||||
}
|
||||
|
||||
/// Start a VM
|
||||
#[openapi(tag = "VM")]
|
||||
#[patch("/api/v1/vm/<id>/start")]
|
||||
|
@ -75,14 +75,19 @@ impl Display for Ticker {
|
||||
pub struct TickerRate(pub Ticker, pub f32);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct CurrencyAmount(pub Currency, u64);
|
||||
pub struct CurrencyAmount(Currency, u64);
|
||||
|
||||
impl CurrencyAmount {
|
||||
const MILLI_SATS: f64 = 1.0e11;
|
||||
|
||||
pub fn millisats(amount: u64) -> Self {
|
||||
CurrencyAmount(Currency::BTC, amount)
|
||||
}
|
||||
|
||||
pub fn from_u64(currency: Currency, amount: u64) -> Self {
|
||||
CurrencyAmount(currency, amount)
|
||||
}
|
||||
|
||||
pub fn from_f32(currency: Currency, amount: f32) -> Self {
|
||||
CurrencyAmount(
|
||||
currency,
|
||||
@ -103,6 +108,10 @@ impl CurrencyAmount {
|
||||
_ => self.1 as f32 / 100.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn currency(&self) -> Currency {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl TickerRate {
|
||||
|
@ -89,8 +89,8 @@ impl RevolutApi {
|
||||
.post(
|
||||
"/api/orders",
|
||||
CreateOrderRequest {
|
||||
currency: amount.0.to_string(),
|
||||
amount: match amount.0 {
|
||||
currency: amount.currency().to_string(),
|
||||
amount: match amount.currency() {
|
||||
Currency::BTC => bail!("Bitcoin amount not allowed for fiat payments"),
|
||||
_ => amount.value(),
|
||||
},
|
||||
|
@ -483,8 +483,23 @@ impl LNVpsProvisioner {
|
||||
/// Create a renewal payment
|
||||
pub async fn renew(&self, vm_id: u64, method: PaymentMethod) -> Result<VmPayment> {
|
||||
let pe = PricingEngine::new(self.db.clone(), self.rates.clone(), self.tax_rates.clone());
|
||||
|
||||
let price = pe.get_vm_cost(vm_id, method).await?;
|
||||
self.price_to_payment(vm_id, method, price).await
|
||||
}
|
||||
|
||||
/// Renew a VM using a specific amount
|
||||
pub async fn renew_amount(&self, vm_id: u64, amount: CurrencyAmount, method: PaymentMethod) -> Result<VmPayment> {
|
||||
let pe = PricingEngine::new(self.db.clone(), self.rates.clone(), self.tax_rates.clone());
|
||||
let price = pe.get_cost_by_amount(vm_id, amount, method).await?;
|
||||
self.price_to_payment(vm_id, method, price).await
|
||||
}
|
||||
|
||||
async fn price_to_payment(
|
||||
&self,
|
||||
vm_id: u64,
|
||||
method: PaymentMethod,
|
||||
price: CostResult,
|
||||
) -> Result<VmPayment> {
|
||||
match price {
|
||||
CostResult::Existing(p) => Ok(p),
|
||||
CostResult::New {
|
||||
|
@ -1,5 +1,5 @@
|
||||
use crate::exchange::{Currency, CurrencyAmount, ExchangeRateService, Ticker, TickerRate};
|
||||
use anyhow::{bail, Result};
|
||||
use anyhow::{bail, ensure, Result};
|
||||
use chrono::{DateTime, Days, Months, TimeDelta, Utc};
|
||||
use ipnetwork::IpNetwork;
|
||||
use isocountry::CountryCode;
|
||||
@ -34,6 +34,49 @@ impl PricingEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get amount of time a certain currency amount will extend a vm in seconds
|
||||
pub async fn get_cost_by_amount(
|
||||
&self,
|
||||
vm_id: u64,
|
||||
input: CurrencyAmount,
|
||||
method: PaymentMethod,
|
||||
) -> Result<CostResult> {
|
||||
let vm = self.db.get_vm(vm_id).await?;
|
||||
|
||||
let cost = if vm.template_id.is_some() {
|
||||
self.get_template_vm_cost(&vm, method).await?
|
||||
} else {
|
||||
self.get_custom_vm_cost(&vm, method).await?
|
||||
};
|
||||
|
||||
match cost {
|
||||
CostResult::Existing(_) => bail!("Invalid response"),
|
||||
CostResult::New {
|
||||
currency,
|
||||
amount,
|
||||
rate,
|
||||
time_value,
|
||||
..
|
||||
} => {
|
||||
ensure!(currency == input.currency(), "Invalid currency");
|
||||
|
||||
// scale cost
|
||||
let scale = input.value() as f64 / amount as f64;
|
||||
let new_time = (time_value as f64 * scale).floor() as u64;
|
||||
ensure!(new_time > 0, "Extend time is less than 1 second");
|
||||
|
||||
Ok(CostResult::New {
|
||||
amount: input.value(),
|
||||
currency,
|
||||
time_value: new_time,
|
||||
new_expiry: vm.expires.add(TimeDelta::seconds(new_time as i64)),
|
||||
rate,
|
||||
tax: self.get_tax_for_user(vm.user_id, input.value()).await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get VM cost (for renewal)
|
||||
pub async fn get_vm_cost(&self, vm_id: u64, method: PaymentMethod) -> Result<CostResult> {
|
||||
let vm = self.db.get_vm(vm_id).await?;
|
||||
@ -160,13 +203,13 @@ impl PricingEngine {
|
||||
}
|
||||
|
||||
async fn get_msats_amount(&self, amount: CurrencyAmount) -> Result<(u64, f32)> {
|
||||
let rate = self.get_ticker(amount.0).await?;
|
||||
let rate = self.get_ticker(amount.currency()).await?;
|
||||
let cost_btc = amount.value_f32() / rate.1;
|
||||
let cost_msats = (cost_btc as f64 * crate::BTC_SATS) as u64 * 1000;
|
||||
Ok((cost_msats, rate.1))
|
||||
}
|
||||
|
||||
fn next_template_expire(vm: &Vm, cost_plan: &VmCostPlan) -> u64 {
|
||||
pub fn next_template_expire(vm: &Vm, cost_plan: &VmCostPlan) -> u64 {
|
||||
let next_expire = match cost_plan.interval_type {
|
||||
VmCostPlanIntervalType::Day => vm.expires.add(Days::new(cost_plan.interval_amount)),
|
||||
VmCostPlanIntervalType::Month => vm
|
||||
@ -209,7 +252,7 @@ impl PricingEngine {
|
||||
list_price: CurrencyAmount,
|
||||
method: PaymentMethod,
|
||||
) -> Result<(Currency, u64, f32)> {
|
||||
Ok(match (list_price.0, method) {
|
||||
Ok(match (list_price.currency(), method) {
|
||||
(c, PaymentMethod::Lightning) if c != Currency::BTC => {
|
||||
let new_price = self.get_msats_amount(list_price).await?;
|
||||
(Currency::BTC, new_price.0, new_price.1)
|
||||
@ -400,6 +443,25 @@ mod tests {
|
||||
_ => bail!("??"),
|
||||
}
|
||||
|
||||
// from amount
|
||||
let price = pe
|
||||
.get_cost_by_amount(1, CurrencyAmount::millisats(1000), PaymentMethod::Lightning)
|
||||
.await?;
|
||||
// full month price in msats
|
||||
let mo_price = (plan.amount / MOCK_RATE * 1.0e11) as u64;
|
||||
let time_scale = 1000f64 / mo_price as f64;
|
||||
let vm = db.get_vm(1).await?;
|
||||
let next_expire = PricingEngine::next_template_expire(&vm, &plan);
|
||||
match price {
|
||||
CostResult::New { amount, time_value, tax, .. } => {
|
||||
let expect_time = (next_expire as f64 * time_scale) as u64;
|
||||
assert_eq!(expect_time, time_value);
|
||||
assert_eq!(0, tax);
|
||||
assert_eq!(amount, 1000);
|
||||
}
|
||||
_ => bail!("??"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user