feat: update revolut order data
Some checks failed
continuous-integration/drone Build is failing

This commit is contained in:
2025-03-14 10:03:52 +00:00
parent 02d606d60c
commit 2d55392050
10 changed files with 540 additions and 289 deletions

536
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View 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;

View File

@ -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)]

View File

@ -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?;

View File

@ -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)]

View File

@ -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(())

View File

@ -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));

View File

@ -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,

View File

@ -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);

View File

@ -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()),
},
);
}