This commit is contained in:
536
Cargo.lock
generated
536
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
5
lnvps_db/migrations/20250313140640_empty_country.sql
Normal file
5
lnvps_db/migrations/20250313140640_empty_country.sql
Normal file
@ -0,0 +1,5 @@
|
||||
alter table users
|
||||
change column country_code country_code varchar (3);
|
||||
-- assume country_code was not actually set until now
|
||||
update users
|
||||
set country_code = null;
|
@ -22,7 +22,7 @@ pub struct User {
|
||||
/// If user should be contacted via email for notifications
|
||||
pub contact_email: bool,
|
||||
/// Users country
|
||||
pub country_code: String,
|
||||
pub country_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(FromRow, Clone, Debug, Default)]
|
||||
|
@ -441,7 +441,8 @@ impl LNVpsDb for LNVpsDbMysql {
|
||||
|
||||
let mut tx = self.db.begin().await?;
|
||||
|
||||
sqlx::query("update vm_payment set is_paid = true where id = ?")
|
||||
sqlx::query("update vm_payment set is_paid = true, external_data = ? where id = ?")
|
||||
.bind(&vm_payment.external_data)
|
||||
.bind(&vm_payment.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
@ -5,7 +5,8 @@ use anyhow::{anyhow, bail, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use ipnetwork::IpNetwork;
|
||||
use lnvps_db::{
|
||||
LNVpsDb, PaymentMethod, Vm, VmCostPlan, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHostRegion, VmTemplate,
|
||||
LNVpsDb, PaymentMethod, Vm, VmCostPlan, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate,
|
||||
VmHostRegion, VmTemplate,
|
||||
};
|
||||
use nostr::util::hex;
|
||||
use schemars::JsonSchema;
|
||||
@ -144,11 +145,12 @@ impl ApiTemplatesResponse {
|
||||
let rates = rates.list_rates().await?;
|
||||
|
||||
for template in &mut self.templates {
|
||||
let list_price = CurrencyAmount(template.cost_plan.currency, template.cost_plan.amount);
|
||||
let list_price =
|
||||
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,
|
||||
amount: alt_price.1,
|
||||
amount: alt_price.value_f32(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -252,7 +254,7 @@ impl From<CurrencyAmount> for ApiPrice {
|
||||
fn from(value: CurrencyAmount) -> Self {
|
||||
Self {
|
||||
currency: value.0,
|
||||
amount: value.1,
|
||||
amount: value.value_f32(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -402,7 +404,7 @@ pub struct AccountPatchRequest {
|
||||
pub email: Option<String>,
|
||||
pub contact_nip17: bool,
|
||||
pub contact_email: bool,
|
||||
pub country_code: String,
|
||||
pub country_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, JsonSchema)]
|
||||
|
@ -108,9 +108,11 @@ async fn v1_patch_account(
|
||||
user.email = req.email.clone();
|
||||
user.contact_nip17 = req.contact_nip17;
|
||||
user.contact_email = req.contact_email;
|
||||
user.country_code = CountryCode::for_alpha3(&req.country_code)?
|
||||
.alpha3()
|
||||
.to_owned();
|
||||
user.country_code = req
|
||||
.country_code
|
||||
.as_ref()
|
||||
.and_then(|c| CountryCode::for_alpha3(c).ok())
|
||||
.map(|c| c.alpha3().to_string());
|
||||
|
||||
db.update_user(&user).await?;
|
||||
ApiData::ok(())
|
||||
|
@ -60,11 +60,35 @@ impl Display for Ticker {
|
||||
pub struct TickerRate(pub Ticker, pub f32);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct CurrencyAmount(pub Currency, pub f32);
|
||||
pub struct CurrencyAmount(pub Currency, u64);
|
||||
|
||||
impl CurrencyAmount {
|
||||
const MILLI_SATS: f64 = 1.0e11;
|
||||
|
||||
pub fn from_u64(currency: Currency, amount: u64) -> Self {
|
||||
CurrencyAmount(currency, amount as f32 / 100.0)
|
||||
CurrencyAmount(currency, amount)
|
||||
}
|
||||
pub fn from_f32(currency: Currency, amount: f32) -> Self {
|
||||
CurrencyAmount(
|
||||
currency,
|
||||
match currency {
|
||||
Currency::EUR => (amount * 100.0) as u64, // cents
|
||||
Currency::BTC => (amount as f64 * Self::MILLI_SATS) as u64, // milli-sats
|
||||
Currency::USD => (amount * 100.0) as u64, // cents
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn value(&self) -> u64 {
|
||||
self.1
|
||||
}
|
||||
|
||||
pub fn value_f32(&self) -> f32 {
|
||||
match self.0 {
|
||||
Currency::EUR => self.1 as f32 / 100.0,
|
||||
Currency::BTC => (self.1 as f64 / Self::MILLI_SATS) as f32,
|
||||
Currency::USD => self.1 as f32 / 100.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -80,9 +104,15 @@ impl TickerRate {
|
||||
"Cant convert, currency doesnt match"
|
||||
);
|
||||
if source.0 == self.0 .0 {
|
||||
Ok(CurrencyAmount(self.0 .1, source.1 * self.1))
|
||||
Ok(CurrencyAmount::from_f32(
|
||||
self.0 .1,
|
||||
source.value_f32() * self.1,
|
||||
))
|
||||
} else {
|
||||
Ok(CurrencyAmount(self.0 .0, source.1 / self.1))
|
||||
Ok(CurrencyAmount::from_f32(
|
||||
self.0 .0,
|
||||
source.value_f32() / self.1,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -177,12 +207,14 @@ mod tests {
|
||||
let f = TickerRate(ticker, RATE);
|
||||
|
||||
assert_eq!(
|
||||
f.convert(CurrencyAmount(Currency::EUR, 5.0)).unwrap(),
|
||||
CurrencyAmount(Currency::BTC, 5.0 / RATE)
|
||||
f.convert(CurrencyAmount::from_f32(Currency::EUR, 5.0))
|
||||
.unwrap(),
|
||||
CurrencyAmount::from_f32(Currency::BTC, 5.0 / RATE)
|
||||
);
|
||||
assert_eq!(
|
||||
f.convert(CurrencyAmount(Currency::BTC, 0.001)).unwrap(),
|
||||
CurrencyAmount(Currency::EUR, RATE * 0.001)
|
||||
f.convert(CurrencyAmount::from_f32(Currency::BTC, 0.001))
|
||||
.unwrap(),
|
||||
CurrencyAmount::from_f32(Currency::EUR, RATE * 0.001)
|
||||
);
|
||||
assert!(!f.can_convert(Currency::USD));
|
||||
assert!(f.can_convert(Currency::EUR));
|
||||
|
@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RevolutApi {
|
||||
api: JsonApi,
|
||||
}
|
||||
@ -63,6 +64,31 @@ impl RevolutApi {
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_order(
|
||||
&self,
|
||||
amount: CurrencyAmount,
|
||||
description: Option<String>,
|
||||
) -> Result<RevolutOrder> {
|
||||
self.api
|
||||
.post(
|
||||
"/api/orders",
|
||||
CreateOrderRequest {
|
||||
currency: amount.0.to_string(),
|
||||
amount: match amount.0 {
|
||||
Currency::BTC => bail!("Bitcoin amount not allowed for fiat payments"),
|
||||
Currency::EUR => amount.value(),
|
||||
Currency::USD => amount.value(),
|
||||
},
|
||||
description,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_order(&self, order_id: &str) -> Result<RevolutOrder> {
|
||||
self.api.get(&format!("/api/orders/{}", order_id)).await
|
||||
}
|
||||
}
|
||||
|
||||
impl FiatPaymentService for RevolutApi {
|
||||
@ -71,24 +97,10 @@ impl FiatPaymentService for RevolutApi {
|
||||
description: &str,
|
||||
amount: CurrencyAmount,
|
||||
) -> Pin<Box<dyn Future<Output = Result<FiatPaymentInfo>> + Send>> {
|
||||
let api = self.api.clone();
|
||||
let s = self.clone();
|
||||
let desc = description.to_string();
|
||||
Box::pin(async move {
|
||||
let rsp: CreateOrderResponse = api
|
||||
.post(
|
||||
"/api/orders",
|
||||
CreateOrderRequest {
|
||||
currency: amount.0.to_string(),
|
||||
amount: match amount.0 {
|
||||
Currency::BTC => bail!("Bitcoin amount not allowed for fiat payments"),
|
||||
Currency::EUR => (amount.1 * 100.0).floor() as u64,
|
||||
Currency::USD => (amount.1 * 100.0).floor() as u64,
|
||||
},
|
||||
description: Some(desc),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let rsp = s.create_order(amount, Some(desc)).await?;
|
||||
Ok(FiatPaymentInfo {
|
||||
raw_data: serde_json::to_string(&rsp)?,
|
||||
external_id: rsp.id,
|
||||
@ -107,22 +119,106 @@ pub struct CreateOrderRequest {
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
pub struct CreateOrderResponse {
|
||||
pub struct RevolutOrder {
|
||||
pub id: String,
|
||||
pub token: String,
|
||||
pub state: PaymentState,
|
||||
pub state: RevolutOrderState,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub description: Option<String>,
|
||||
pub amount: u64,
|
||||
pub currency: String,
|
||||
pub outstanding_amount: u64,
|
||||
pub checkout_url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub checkout_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub payments: Option<Vec<RevolutOrderPayment>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
pub struct RevolutOrderPayment {
|
||||
pub id: String,
|
||||
pub state: RevolutPaymentState,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub decline_reason: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bank_message: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub token: Option<String>,
|
||||
pub amount: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub currency: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub settled_amount: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub settled_currency: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub payment_method: Option<RevolutPaymentMethod>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub billing_address: Option<RevolutBillingAddress>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub risk_level: Option<RevolutRiskLevel>
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
pub struct RevolutPaymentMethod {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: RevolutPaymentMethodType,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub card_brand: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub funding: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub card_country_code: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub card_bin: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub card_last_four: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub card_expiry: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cardholder_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RevolutPaymentMethodType {
|
||||
ApplePay,
|
||||
Card,
|
||||
GooglePay,
|
||||
RevolutPayCard,
|
||||
RevolutPayAccount,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RevolutRiskLevel {
|
||||
High,
|
||||
Low
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
pub struct RevolutBillingAddress {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub street_line_1: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub street_line_2: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub region: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub city: Option<String>,
|
||||
|
||||
pub country_code: String,
|
||||
pub postcode: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PaymentState {
|
||||
pub enum RevolutOrderState {
|
||||
Pending,
|
||||
Processing,
|
||||
Authorised,
|
||||
@ -131,6 +227,31 @@ pub enum PaymentState {
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RevolutPaymentState {
|
||||
Pending,
|
||||
AuthenticationChallenge,
|
||||
AuthenticationVerified,
|
||||
AuthorisationStarted,
|
||||
AuthorisationPassed,
|
||||
Authorised,
|
||||
CaptureStarted,
|
||||
Captured,
|
||||
RefundValidated,
|
||||
RefundStarted,
|
||||
CancellationStarted,
|
||||
Declining,
|
||||
Completing,
|
||||
Cancelling,
|
||||
Failing,
|
||||
Completed,
|
||||
Declined,
|
||||
SoftDeclined,
|
||||
Cancelled,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
pub struct RevolutWebhook {
|
||||
pub id: String,
|
||||
|
@ -4,6 +4,7 @@ use crate::settings::RevolutConfig;
|
||||
use crate::worker::WorkJob;
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use hmac::{Hmac, Mac};
|
||||
use isocountry::CountryCode;
|
||||
use lnvps_db::LNVpsDb;
|
||||
use log::{error, info, warn};
|
||||
use reqwest::Url;
|
||||
@ -73,7 +74,30 @@ impl RevolutPaymentHandler {
|
||||
}
|
||||
|
||||
async fn try_complete_payment(&self, ext_id: &str) -> Result<()> {
|
||||
let p = self.db.get_vm_payment_by_ext_id(ext_id).await?;
|
||||
let mut p = self.db.get_vm_payment_by_ext_id(ext_id).await?;
|
||||
|
||||
// save payment state json into external_data
|
||||
// TODO: encrypt payment_data
|
||||
let order = self.api.get_order(ext_id).await?;
|
||||
p.external_data = serde_json::to_string(&order)?;
|
||||
|
||||
// check user country matches card country
|
||||
if let Some(cc) = order
|
||||
.payments
|
||||
.and_then(|p| p.first().cloned())
|
||||
.and_then(|p| p.payment_method)
|
||||
.and_then(|p| p.card_country_code)
|
||||
.and_then(|c| CountryCode::for_alpha2(&c).ok())
|
||||
{
|
||||
let vm = self.db.get_vm(p.vm_id).await?;
|
||||
let mut user = self.db.get_user(vm.user_id).await?;
|
||||
if user.country_code.is_none() {
|
||||
// update user country code to match card country
|
||||
user.country_code = Some(cc.alpha3().to_string());
|
||||
self.db.update_user(&user).await?;
|
||||
}
|
||||
}
|
||||
|
||||
self.db.vm_payment_paid(&p).await?;
|
||||
self.sender.send(WorkJob::CheckVm { vm_id: p.vm_id })?;
|
||||
info!("VM payment {} for {}, paid", hex::encode(p.id), p.vm_id);
|
||||
|
@ -122,7 +122,10 @@ 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 (currency, amount, rate) = self
|
||||
.get_amount_and_rate(CurrencyAmount(price.currency, price.total()), method)
|
||||
.get_amount_and_rate(
|
||||
CurrencyAmount::from_f32(price.currency, price.total()),
|
||||
method,
|
||||
)
|
||||
.await?;
|
||||
Ok(CostResult::New {
|
||||
amount,
|
||||
@ -136,12 +139,15 @@ impl PricingEngine {
|
||||
|
||||
async fn get_tax_for_user(&self, user_id: u64, amount: u64) -> Result<u64> {
|
||||
let user = self.db.get_user(user_id).await?;
|
||||
let cc = CountryCode::for_alpha3(&user.country_code).context("Invalid country code")?;
|
||||
if let Some(c) = self.tax_rates.get(&cc) {
|
||||
Ok((amount as f64 * (*c as f64 / 100f64)).floor() as u64)
|
||||
} else {
|
||||
Ok(0)
|
||||
if let Some(cc) = user
|
||||
.country_code
|
||||
.and_then(|c| CountryCode::for_alpha3(&c).ok())
|
||||
{
|
||||
if let Some(c) = self.tax_rates.get(&cc) {
|
||||
return Ok((amount as f64 * (*c as f64 / 100f64)).floor() as u64);
|
||||
}
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
async fn get_ticker(&self, currency: Currency) -> Result<TickerRate> {
|
||||
@ -155,7 +161,7 @@ impl PricingEngine {
|
||||
|
||||
async fn get_msats_amount(&self, amount: CurrencyAmount) -> Result<(u64, f32)> {
|
||||
let rate = self.get_ticker(amount.0).await?;
|
||||
let cost_btc = amount.1 / rate.1;
|
||||
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))
|
||||
}
|
||||
@ -185,7 +191,7 @@ impl PricingEngine {
|
||||
|
||||
let currency = cost_plan.currency.parse().expect("Invalid currency");
|
||||
let (currency, amount, rate) = self
|
||||
.get_amount_and_rate(CurrencyAmount(currency, cost_plan.amount), method)
|
||||
.get_amount_and_rate(CurrencyAmount::from_f32(currency, cost_plan.amount), method)
|
||||
.await?;
|
||||
let time_value = Self::next_template_expire(vm, &cost_plan);
|
||||
Ok(CostResult::New {
|
||||
@ -209,7 +215,7 @@ impl PricingEngine {
|
||||
(Currency::BTC, new_price.0, new_price.1)
|
||||
}
|
||||
(cur, PaymentMethod::Revolut) if cur != Currency::BTC => {
|
||||
(cur, (list_price.1 * 100.0).ceil() as u64, 0.01)
|
||||
(cur, list_price.value(), 0.01)
|
||||
}
|
||||
(c, m) => bail!("Cannot create payment for method {} and currency {}", m, c),
|
||||
})
|
||||
@ -349,7 +355,7 @@ mod tests {
|
||||
email: None,
|
||||
contact_nip17: false,
|
||||
contact_email: false,
|
||||
country_code: "USA".to_string(),
|
||||
country_code: Some("USA".to_string()),
|
||||
},
|
||||
);
|
||||
u.insert(
|
||||
@ -361,7 +367,7 @@ mod tests {
|
||||
email: None,
|
||||
contact_nip17: false,
|
||||
contact_email: false,
|
||||
country_code: "IRL".to_string(),
|
||||
country_code: Some("IRL".to_string()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
Reference in New Issue
Block a user