From 9c3733cb544b1c517ddb0a2db9637a6e6608f0b6 Mon Sep 17 00:00:00 2001 From: Kieran Date: Tue, 21 Jul 2026 10:48:15 +0100 Subject: [PATCH 1/2] feat(payments): payment method minimum amount + exchange improvements (#170) Add a configurable minimum processable amount per payment method config so uneconomic small charges are rejected (e.g. Revolut's flat 20c base fee dominating a tiny payment). Lightning has no minimum. - DB: add min_amount / min_amount_currency to payment_method_config (migration + model + insert/update queries) - Enforce in the subscription renewal path against the gross total (net + tax + processing fee), converting the configured minimum into the payment currency - Expose on the public GET /api/v1/payment/methods and admin payment method config create/update/get + docs Exchange module improvements needed for cross-currency minimums: - Integer-precise conversion (f64 on smallest-unit values) instead of routing through f32, which lost a unit (e.g. EUR1.00 -> 999,999 msat) - Pull fiat FX rates (base EUR, frankfurter.app/ECB) alongside BTC prices; PricingEngine::convert_currency resolves any pair directly, inverse, or via a BTC/EUR cross rate - Properly convert the processing-fee base into the tx currency Includes regression tests for precision, cross-currency conversion and min-amount enforcement. --- ADMIN_API_ENDPOINTS.md | 28 +- API_CHANGELOG.md | 2 + API_DOCUMENTATION.md | 2 + lnvps_api/src/api/model.rs | 9 + lnvps_api/src/api/routes.rs | 8 + .../data_migration/payment_method_config.rs | 5 +- lnvps_api/src/subscription/mod.rs | 14 + lnvps_api_admin/src/admin/model.rs | 35 +++ lnvps_api_admin/src/admin/payment_methods.rs | 25 ++ lnvps_api_common/src/exchange.rs | 135 ++++++-- lnvps_api_common/src/pricing.rs | 295 +++++++++++++++++- lnvps_api_common/src/shasum.rs | 12 +- ...260721102854_payment_method_min_amount.sql | 8 + lnvps_db/src/model.rs | 8 + lnvps_db/src/mysql.rs | 12 +- 15 files changed, 563 insertions(+), 35 deletions(-) create mode 100644 lnvps_db/migrations/20260721102854_payment_method_min_amount.sql diff --git a/ADMIN_API_ENDPOINTS.md b/ADMIN_API_ENDPOINTS.md index 5dbb86e4..a3e7385d 100644 --- a/ADMIN_API_ENDPOINTS.md +++ b/ADMIN_API_ENDPOINTS.md @@ -5104,8 +5104,14 @@ Body: | null, // Optional - Base fee in smallest currency units (e.g., 20 for €0.20) - "processing_fee_currency": "string | null" + "processing_fee_currency": "string | null", // Required if processing_fee_base is set - Currency code (e.g., "EUR") + "min_amount": number + | + null, + // Optional - Minimum processable amount in smallest currency units (e.g., 100 for €1.00). Payments below this are rejected for this method. + "min_amount_currency": "string | null" + // Required if min_amount is set - Currency code (e.g., "EUR") } ``` @@ -5199,8 +5205,14 @@ Body (all fields optional): | null, // Base fee in smallest currency units (e.g., 20 for €0.20, null to clear) - "processing_fee_currency": "string | null" + "processing_fee_currency": "string | null", // Currency code (required if base fee is set) + "min_amount": number + | + null, + // Minimum processable amount in smallest currency units (null to clear) + "min_amount_currency": "string | null" + // Currency code (required if min_amount is set) } ``` @@ -5394,6 +5406,12 @@ Instead, the config contains boolean indicators showing whether these values are // Base fee in smallest currency units (e.g., 20 for €0.20) "processing_fee_currency": "string | null", // Currency code for base fee + "min_amount": number + | + null, + // Minimum processable amount in smallest currency units (e.g., 100 for €1.00) + "min_amount_currency": "string | null", + // Currency code for the minimum amount "supported_currencies": ["string"], // Supported currency codes (e.g., ["EUR", "USD"]). Empty array means use defaults. "created": "string (ISO 8601)", @@ -5499,6 +5517,12 @@ The `config` field of `AdminPaymentMethodConfigInfo` is a tagged union — the ` // Optional - Base fee in smallest currency units (e.g., 20 for €0.20) "processing_fee_currency": "string | null", // Required if base fee is set + "min_amount": number + | + null, + // Optional - Minimum processable amount in smallest currency units (e.g., 100 for €1.00) + "min_amount_currency": "string | null", + // Required if min_amount is set "supported_currencies": ["string"] | null // Optional - Supported currency codes (e.g., ["EUR", "USD"]) } diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index 41171a4e..9d3cf0c0 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -8,10 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- **Payment method minimum amount** — payment method configs now support a `min_amount` (+ `min_amount_currency`) in smallest currency units. Payments whose gross total (net + tax + processing fee) is below the configured minimum are rejected for that method, avoiding uneconomic small charges (e.g. Revolut's flat 20c base fee dominating a tiny payment). Exposed on the public `GET /api/v1/payment/methods` and admin `vm`/payment method config endpoints (`GET/POST/PATCH /api/admin/v1/payment_method_configs`). Lightning has no minimum. Fixes #170. - **New OS distributions** — the `OsDistribution` / `ApiOsDistribution` enum now includes `almalinux`, `rockylinux`, `alpine`, `nixos`, `openbsd`, `netbsd`, `gentoo`, and `voidlinux`. Affects `GET /api/v1/image`, VM status responses, and the admin VM OS image endpoints (`GET/POST/PATCH /api/admin/v1/vm_os_image`). ### Changed +- **Exchange module: precise conversions + fiat FX rates** — currency conversion is now integer-precise (done in `f64` on smallest-unit values instead of routing through `f32`, which previously lost a unit, e.g. €1.00 → 999,999 msat instead of 1,000,000). The exchange service now also pulls fiat FX rates (base EUR, from frankfurter.app / ECB) alongside BTC prices, and the pricing engine can resolve arbitrary currency pairs directly, inverse, or via a BTC/EUR cross rate. The processing-fee base amount is now properly converted into the transaction currency instead of being used as-is. - **OS image downloads now run in parallel across hosts** — the `DownloadOsImages` worker job previously processed hosts strictly sequentially (each host waited for the previous host to finish all images). Hosts are now processed concurrently; images on a single host remain sequential to avoid saturating that host's storage backend. Affects the admin `POST /api/admin/v1/vm_os_images/{id}/download` endpoint and the periodic image check. - **DNS is now best-effort during VM provisioning** — forward (A/AAAA) and reverse (PTR) DNS records are convenience only and no longer block or roll back a deploy. Previously a reverse-DNS failure (notably OVH rejecting a PTR with a `4xx` until the forward name resolves — a fatal, non-retried error) tore down the whole pipeline and destroyed the freshly-created VM. Now such failures are logged and the deploy proceeds; the VM keeps its IPs and MAC. Any records that failed to create are reconciled automatically on the periodic VM check (and can still be forced via the existing DNS patch job). diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index bc7c81cc..239bd0f4 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -419,6 +419,8 @@ interface PaymentMethod { processing_fee_rate?: number; // Percentage rate (e.g., 1.0 for 1%) processing_fee_base?: number; // Base amount in smallest currency units (cents for fiat, millisats for BTC) processing_fee_currency?: string; // Currency for the base fee (e.g., "EUR") + min_amount?: number; // Minimum processable amount in smallest currency units; payments below this are rejected for this method + min_amount_currency?: string; // Currency for min_amount (e.g., "EUR") } ``` diff --git a/lnvps_api/src/api/model.rs b/lnvps_api/src/api/model.rs index f35ab512..5d5286a8 100644 --- a/lnvps_api/src/api/model.rs +++ b/lnvps_api/src/api/model.rs @@ -467,6 +467,15 @@ pub struct ApiPaymentInfo { /// Currency for the processing fee base #[serde(skip_serializing_if = "Option::is_none")] pub processing_fee_currency: Option, + + /// Minimum processable amount in smallest units of `min_amount_currency` + /// (cents for fiat, millisats for BTC). Payments below this are rejected. + #[serde(skip_serializing_if = "Option::is_none")] + pub min_amount: Option, + + /// Currency for `min_amount` + #[serde(skip_serializing_if = "Option::is_none")] + pub min_amount_currency: Option, } /// Payment data related to the payment method diff --git a/lnvps_api/src/api/routes.rs b/lnvps_api/src/api/routes.rs index 81992845..a64bd7a5 100644 --- a/lnvps_api/src/api/routes.rs +++ b/lnvps_api/src/api/routes.rs @@ -1488,6 +1488,8 @@ fn build_payment_methods_response( processing_fee_rate: config.processing_fee_rate, processing_fee_base: config.processing_fee_base, processing_fee_currency: config.processing_fee_currency, + min_amount: config.min_amount, + min_amount_currency: config.min_amount_currency, } }) .collect(); @@ -1501,6 +1503,8 @@ fn build_payment_methods_response( processing_fee_rate: None, processing_fee_base: None, processing_fee_currency: None, + min_amount: None, + min_amount_currency: None, }); ret.push(ApiPaymentInfo { name: ApiPaymentMethod::LNURL, @@ -1509,6 +1513,8 @@ fn build_payment_methods_response( processing_fee_rate: None, processing_fee_base: None, processing_fee_currency: None, + min_amount: None, + min_amount_currency: None, }); } @@ -2202,6 +2208,8 @@ mod tests { processing_fee_rate: fee_rate, processing_fee_base: fee_base, processing_fee_currency: fee_currency.map(String::from), + min_amount: None, + min_amount_currency: None, supported_currencies: lnvps_db::CommaSeparated::new( supported_currencies.into_iter().map(String::from).collect(), ), diff --git a/lnvps_api/src/data_migration/payment_method_config.rs b/lnvps_api/src/data_migration/payment_method_config.rs index bbc8e3f8..7523f3b1 100644 --- a/lnvps_api/src/data_migration/payment_method_config.rs +++ b/lnvps_api/src/data_migration/payment_method_config.rs @@ -38,9 +38,8 @@ impl DataMigration for PaymentMethodConfigMigration { // "any config exists" check would prevent newly added methods // (e.g. on-chain) from ever being imported on existing deployments. let existing_configs = db.list_payment_method_configs().await?; - let has_method = |m: PaymentMethod| { - existing_configs.iter().any(|c| c.payment_method == m) - }; + let has_method = + |m: PaymentMethod| existing_configs.iter().any(|c| c.payment_method == m); let need_lightning = !has_method(PaymentMethod::Lightning); // On-chain is only seeded from an LND wallet diff --git a/lnvps_api/src/subscription/mod.rs b/lnvps_api/src/subscription/mod.rs index 88e5b1a2..af3ba5ac 100644 --- a/lnvps_api/src/subscription/mod.rs +++ b/lnvps_api/src/subscription/mod.rs @@ -577,6 +577,20 @@ impl SubscriptionHandler { let converted_amount = total_amount; let converted_currency = payment_currency; + // Reject payments below the method's configured minimum (gross = net + tax + // + processing fee). Prevents uneconomic small charges (e.g. Revolut's + // flat 20c base fee dominating a tiny payment). + self.pe + .enforce_min_amount( + subscription.company_id, + method, + CurrencyAmount::from_u64( + converted_currency, + converted_amount + tax + processing_fee, + ), + ) + .await?; + // Generate payment based on method let subscription_payment = match method { PaymentMethod::Lightning => { diff --git a/lnvps_api_admin/src/admin/model.rs b/lnvps_api_admin/src/admin/model.rs index 868feaa6..17a1d878 100644 --- a/lnvps_api_admin/src/admin/model.rs +++ b/lnvps_api_admin/src/admin/model.rs @@ -3698,6 +3698,11 @@ pub struct AdminPaymentMethodConfigInfo { pub processing_fee_base: Option, /// Currency for the processing fee base pub processing_fee_currency: Option, + /// Minimum processable amount in smallest currency units (cents for fiat, + /// millisats for BTC). Payments below this are rejected for this method. + pub min_amount: Option, + /// Currency for the minimum amount + pub min_amount_currency: Option, /// Supported currency codes (e.g., ["EUR", "USD"]) pub supported_currencies: Vec, pub created: DateTime, @@ -3720,6 +3725,8 @@ impl From for AdminPaymentMethodConfigInfo { processing_fee_rate: config.processing_fee_rate, processing_fee_base: config.processing_fee_base, processing_fee_currency: config.processing_fee_currency, + min_amount: config.min_amount, + min_amount_currency: config.min_amount_currency, supported_currencies: config.supported_currencies.into_inner(), created: config.created, modified: config.modified, @@ -3740,6 +3747,10 @@ pub struct CreatePaymentMethodConfigRequest { /// Processing fee base in smallest currency units (cents for fiat, millisats for BTC) pub processing_fee_base: Option, pub processing_fee_currency: Option, + /// Minimum processable amount in smallest currency units (cents for fiat, + /// millisats for BTC) + pub min_amount: Option, + pub min_amount_currency: Option, /// Supported currency codes (e.g., ["EUR", "USD"]) pub supported_currencies: Option>, } @@ -3757,6 +3768,13 @@ impl CreatePaymentMethodConfigRequest { )); } + // Validate that if min amount is set, currency must also be set + if self.min_amount.is_some() && self.min_amount_currency.is_none() { + return Err(anyhow!( + "Minimum amount currency is required when minimum amount is set" + )); + } + let mut payment_config = lnvps_db::PaymentMethodConfig::new_with_config( self.company_id, self.config.payment_method(), @@ -3770,6 +3788,11 @@ impl CreatePaymentMethodConfigRequest { .processing_fee_currency .as_ref() .map(|s| s.trim().to_uppercase()); + payment_config.min_amount = self.min_amount; + payment_config.min_amount_currency = self + .min_amount_currency + .as_ref() + .map(|s| s.trim().to_uppercase()); if let Some(currencies) = &self.supported_currencies { payment_config.supported_currencies = lnvps_db::CommaSeparated::new( currencies.iter().map(|s| s.trim().to_uppercase()).collect(), @@ -3803,6 +3826,18 @@ pub struct UpdatePaymentMethodConfigRequest { deserialize_with = "lnvps_api_common::deserialize_nullable_option" )] pub processing_fee_currency: Option>, + /// Minimum processable amount in smallest currency units (cents for fiat, + /// millisats for BTC) + #[serde( + default, + deserialize_with = "lnvps_api_common::deserialize_nullable_option" + )] + pub min_amount: Option>, + #[serde( + default, + deserialize_with = "lnvps_api_common::deserialize_nullable_option" + )] + pub min_amount_currency: Option>, /// Supported currency codes (e.g., ["EUR", "USD"]) pub supported_currencies: Option>, } diff --git a/lnvps_api_admin/src/admin/payment_methods.rs b/lnvps_api_admin/src/admin/payment_methods.rs index b5285930..a5d6af56 100644 --- a/lnvps_api_admin/src/admin/payment_methods.rs +++ b/lnvps_api_admin/src/admin/payment_methods.rs @@ -145,6 +145,31 @@ async fn admin_update_payment_method( .into()); } + // Handle currency first so we can validate the min amount against it + if let Some(currency) = &request.min_amount_currency { + config.min_amount_currency = currency.as_ref().map(|s| s.trim().to_uppercase()); + } + if let Some(min) = request.min_amount { + config.min_amount = match (min, &config.min_amount_currency) { + (Some(amount), Some(_)) => Some(amount), + (None, _) => None, + (Some(_), None) => { + return Err(anyhow::anyhow!( + "Minimum amount currency is required when minimum amount is set" + ) + .into()); + } + }; + } + + // Validate that if min amount is set, currency must also be set + if config.min_amount.is_some() && config.min_amount_currency.is_none() { + return Err(anyhow::anyhow!( + "Minimum amount currency is required when minimum amount is set" + ) + .into()); + } + // Update supported currencies if provided if let Some(currencies) = &request.supported_currencies { config.supported_currencies = lnvps_db::CommaSeparated::new( diff --git a/lnvps_api_common/src/exchange.rs b/lnvps_api_common/src/exchange.rs index f6256e97..c7c0cf18 100644 --- a/lnvps_api_common/src/exchange.rs +++ b/lnvps_api_common/src/exchange.rs @@ -80,22 +80,35 @@ impl TickerRate { currency == self.ticker.0 || currency == self.ticker.1 } - /// Convert from the source currency into the target currency + /// Convert from the source currency into the target currency. + /// + /// The math is done in `f64` on the integer smallest-unit values (cents / + /// milli-sats) and only rounded to `u64` at the end. Routing through + /// `f32`/`value_f32()` (as an earlier version did) lost precision — e.g. + /// €1.00 at 100,000 EUR/BTC produced 999,999 msat instead of 1,000,000. pub fn convert(&self, source: CurrencyAmount) -> Result { ensure!( self.can_convert(source.currency()), "Cant convert, currency doesnt match" ); - if source.currency() == self.ticker.0 { - Ok(CurrencyAmount::from_f32( - self.ticker.1, - source.value_f32() * self.rate, - )) + let rate = self.rate as f64; + let (target, factor) = if source.currency() == self.ticker.0 { + (self.ticker.1, rate) } else { - Ok(CurrencyAmount::from_f32( - self.ticker.0, - source.value_f32() / self.rate, - )) + (self.ticker.0, 1.0 / rate) + }; + let src_standard = source.value() as f64 / Self::scale(source.currency()); + let dst_standard = src_standard * factor; + let dst_smallest = (dst_standard * Self::scale(target)).round() as u64; + Ok(CurrencyAmount::from_u64(target, dst_smallest)) + } + + /// Number of smallest units per standard unit for a currency, matching + /// `payments_rs` (BTC = 1e11 milli-sats, all fiat = 100 cents). + fn scale(currency: Currency) -> f64 { + match currency { + Currency::BTC => 1.0e11, + _ => 100.0, } } @@ -145,6 +158,41 @@ pub fn alt_prices(rates: &Vec, source: CurrencyAmount) -> Vecfiat pairs can be resolved (directly or via a EUR cross). +pub async fn fetch_fiat_fx_rates() -> Result> { + let symbols = FX_CURRENCIES + .iter() + .map(|c| c.to_string()) + .collect::>() + .join(","); + let url = format!("https://api.frankfurter.app/latest?base=EUR&symbols={symbols}"); + let rsp = reqwest::get(&url).await?.text().await?; + let parsed: FrankfurterRates = serde_json::from_str(&rsp)?; + let mut ret = Vec::new(); + for cur in FX_CURRENCIES { + if let Some(rate) = parsed.rates.get(&cur.to_string()) { + ret.push(TickerRate { + ticker: Ticker(Currency::EUR, *cur), + rate: *rate, + }); + } + } + Ok(ret) +} + #[derive(Clone, Default)] pub struct InMemoryRateCache { cache: Arc>>, @@ -203,6 +251,12 @@ impl ExchangeRateService for InMemoryRateCache { }); } + // Also pull fiat FX (base EUR) so fiat<->fiat pairs are resolvable. + match fetch_fiat_fx_rates().await { + Ok(mut fx) => ret.append(&mut fx), + Err(e) => error!("Failed to fetch fiat FX rates: {}", e), + } + Ok(ret) } @@ -342,7 +396,24 @@ impl RedisExchangeRateService { .await?; } - trace!("Fetched and cached {} rates from mempool.space", ret.len()); + // Also pull fiat FX (base EUR) so fiat<->fiat pairs are resolvable. + match fetch_fiat_fx_rates().await { + Ok(fx) => { + for ticker_rate in fx { + let _: () = conn + .set_ex( + self.ticker_key(&ticker_rate.ticker), + ticker_rate.rate, + self.cache_ttl.as_secs(), + ) + .await?; + ret.push(ticker_rate); + } + } + Err(e) => error!("Failed to fetch fiat FX rates: {}", e), + } + + trace!("Fetched and cached {} rates", ret.len()); Ok(ret) } } @@ -399,6 +470,12 @@ impl ExchangeRateService for RedisExchangeRateService { } } +#[derive(Deserialize)] +struct FrankfurterRates { + #[serde(default)] + pub rates: HashMap, +} + #[derive(Deserialize)] struct MempoolRates { #[serde(rename = "USD")] @@ -425,23 +502,41 @@ mod tests { #[test] fn convert() { let ticker = Ticker::btc_rate("EUR").unwrap(); - let f = TickerRate { - ticker: ticker, - rate: RATE, - }; + let f = TickerRate { ticker, rate: RATE }; + // €5.00 / 95,000 = 5.263157894...e-5 BTC = 5,263,157.89 msat -> 5,263,158 assert_eq!( - f.convert(CurrencyAmount::from_f32(Currency::EUR, 5.0)) + f.convert(CurrencyAmount::from_u64(Currency::EUR, 500)) .unwrap(), - CurrencyAmount::from_f32(Currency::BTC, 5.0 / RATE) + CurrencyAmount::millisats(5_263_158) ); + // 0.001 BTC * 95,000 = €95.00 = 9500 cents assert_eq!( - f.convert(CurrencyAmount::from_f32(Currency::BTC, 0.001)) - .unwrap(), - CurrencyAmount::from_f32(Currency::EUR, RATE * 0.001) + f.convert(CurrencyAmount::millisats(100_000_000)).unwrap(), + CurrencyAmount::from_u64(Currency::EUR, 9500) ); assert!(!f.can_convert(Currency::USD)); assert!(f.can_convert(Currency::EUR)); assert!(f.can_convert(Currency::BTC)); } + + /// Regression: conversions must be integer-precise, not lose a unit to f32. + #[test] + fn convert_precise_no_f32_loss() { + let f = TickerRate { + ticker: Ticker::btc_rate("EUR").unwrap(), + rate: 100_000.0, + }; + // €1.00 at 100,000 EUR/BTC = exactly 1e-5 BTC = 1,000,000 msat + assert_eq!( + f.convert(CurrencyAmount::from_u64(Currency::EUR, 100)) + .unwrap(), + CurrencyAmount::millisats(1_000_000) + ); + // Round-trip back to EUR is exact + assert_eq!( + f.convert(CurrencyAmount::millisats(1_000_000)).unwrap(), + CurrencyAmount::from_u64(Currency::EUR, 100) + ); + } } diff --git a/lnvps_api_common/src/pricing.rs b/lnvps_api_common/src/pricing.rs index 83440579..91e1da04 100644 --- a/lnvps_api_common/src/pricing.rs +++ b/lnvps_api_common/src/pricing.rs @@ -375,11 +375,24 @@ impl PricingEngine { // Same currency, use directly base } else { - // TODO: Implement proper currency conversion for the base fee - // For now, use the base fee value as-is in the transaction currency - // This is a simplification - in production, this should use the - // exchange rate service to convert the fee to the target currency - base + // Convert the flat base fee into the transaction currency using the + // exchange service (resolving a cross-rate path if needed). If no + // path is available, fall back to the raw value in the tx currency. + match self + .convert_currency(CurrencyAmount::from_u64(base_fee_currency, base), currency) + .await + { + Ok(converted) => converted.value(), + Err(e) => { + log::warn!( + "Failed to convert processing fee base {} -> {}: {}; using raw value", + base_fee_currency, + currency, + e + ); + base + } + } }; // Gross-up the flat base fee too — the provider takes their percentage cut on the @@ -393,6 +406,57 @@ impl PricingEngine { percentage_fee + base_fee } + /// Enforce the configured minimum processable amount for a payment method. + /// + /// `gross` is the total the provider will actually process (net + tax + + /// processing fee) in `gross.currency()`. If the method's config has a + /// `min_amount` set, it is converted into the gross currency and compared; + /// amounts below the minimum are rejected. Lightning has no minimum. + pub async fn enforce_min_amount( + &self, + company_id: u64, + method: PaymentMethod, + gross: CurrencyAmount, + ) -> Result<()> { + if method == PaymentMethod::Lightning { + return Ok(()); + } + let config = match self + .db + .get_payment_method_config_for_company(company_id, method) + .await + { + Ok(c) => c, + // No config -> nothing to enforce (fee calc logs this case already) + Err(_) => return Ok(()), + }; + let (Some(min_raw), Some(min_currency_str)) = + (config.min_amount, config.min_amount_currency.as_deref()) + else { + return Ok(()); + }; + if min_raw == 0 { + return Ok(()); + } + let min_currency = Currency::from_str(min_currency_str) + .map_err(|_| anyhow!("Invalid min_amount_currency '{}'", min_currency_str))?; + // Convert the configured minimum into the gross currency (resolving a + // cross-rate path if there is no direct pair). + let min_in_gross = self + .convert_currency( + CurrencyAmount::from_u64(min_currency, min_raw), + gross.currency(), + ) + .await?; + ensure!( + gross.value() >= min_in_gross.value(), + "Amount is below the minimum for this payment method ({} {})", + min_in_gross.value_f32(), + gross.currency() + ); + Ok(()) + } + /// Get amount of time a certain currency amount will extend a vm in seconds pub async fn get_cost_by_amount( &self, @@ -841,6 +905,55 @@ impl PricingEngine { } } + /// Find a rate that can convert between `a` and `b` in either stored + /// direction (rates are registered canonically, e.g. `BTC/EUR`, while + /// [`TickerRate::convert`] handles both directions). + async fn find_rate(&self, a: Currency, b: Currency) -> Option { + if a == b { + return Some(TickerRate::passthrough(a)); + } + if let Ok(t) = self.get_ticker(a, b).await { + return Some(t); + } + self.get_ticker(b, a).await.ok() + } + + /// Convert `source` into `target`, resolving a path through available rates. + /// + /// Resolution order: passthrough, a direct/inverse pair, then a cross rate + /// via an intermediary (BTC, then EUR). This lets arbitrary fiat<->fiat and + /// fiat<->BTC pairs convert using the BTC prices and fiat FX rates fetched + /// into the exchange service. + pub async fn convert_currency( + &self, + source: CurrencyAmount, + target: Currency, + ) -> Result { + if source.currency() == target { + return Ok(source); + } + if let Some(rate) = self.find_rate(source.currency(), target).await { + return rate.convert(source); + } + for mid in [Currency::BTC, Currency::EUR] { + if mid == source.currency() || mid == target { + continue; + } + if let (Some(first), Some(second)) = ( + self.find_rate(source.currency(), mid).await, + self.find_rate(mid, target).await, + ) { + let intermediate = first.convert(source)?; + return second.convert(intermediate); + } + } + bail!( + "No conversion path from {} to {}", + source.currency(), + target + ) + } + pub fn next_template_expire(base_expiry: DateTime, cost_plan: &VmCostPlan) -> u64 { // Clamp the base to now so expired VMs get a sensible time_value let base = base_expiry.max(Utc::now()); @@ -3375,4 +3488,176 @@ mod tests { assert_eq!(d.amount, 2300); Ok(()) } + + async fn add_revolut_min_amount_config(db: &MockDb, min_amount: u64, min_currency: &str) { + use lnvps_db::{ProviderConfig, RevolutProviderConfig}; + let mut configs = db.payment_method_configs.lock().await; + let mut config = PaymentMethodConfig::new_with_config( + 1, + PaymentMethod::Revolut, + "Revolut".to_string(), + true, + ProviderConfig::Revolut(RevolutProviderConfig { + url: "https://api.revolut.com".to_string(), + token: "test-token".to_string(), + api_version: "2024-09-01".to_string(), + public_key: "pk_test".to_string(), + webhook_secret: None, + }), + ); + config.id = 1; + config.min_amount = Some(min_amount); + config.min_amount_currency = Some(min_currency.to_string()); + configs.insert(1, config); + } + + #[tokio::test] + async fn min_amount_rejects_below_and_allows_at_or_above() -> Result<()> { + let db = MockDb::default(); + add_revolut_min_amount_config(&db, 100, "EUR").await; // €1.00 minimum + let pe = make_pe(Arc::new(db)).await; + + // Below the minimum -> rejected + assert!( + pe.enforce_min_amount( + 1, + PaymentMethod::Revolut, + CurrencyAmount::from_u64(Currency::EUR, 99) + ) + .await + .is_err() + ); + // At the minimum -> allowed + assert!( + pe.enforce_min_amount( + 1, + PaymentMethod::Revolut, + CurrencyAmount::from_u64(Currency::EUR, 100) + ) + .await + .is_ok() + ); + Ok(()) + } + + #[tokio::test] + async fn min_amount_lightning_always_allowed() -> Result<()> { + let db = MockDb::default(); + let pe = make_pe(Arc::new(db)).await; + assert!( + pe.enforce_min_amount(1, PaymentMethod::Lightning, CurrencyAmount::millisats(1)) + .await + .is_ok() + ); + Ok(()) + } + + #[tokio::test] + async fn min_amount_no_config_or_unset_is_allowed() -> Result<()> { + // No config at all + let pe = make_pe(Arc::new(MockDb::default())).await; + assert!( + pe.enforce_min_amount( + 1, + PaymentMethod::Revolut, + CurrencyAmount::from_u64(Currency::EUR, 1) + ) + .await + .is_ok() + ); + // Config present but no min_amount set + let db = MockDb::default(); + add_revolut_processing_fee_config(&db).await; + let pe = make_pe(Arc::new(db)).await; + assert!( + pe.enforce_min_amount( + 1, + PaymentMethod::Revolut, + CurrencyAmount::from_u64(Currency::EUR, 1) + ) + .await + .is_ok() + ); + Ok(()) + } + + #[tokio::test] + async fn convert_currency_cross_via_btc() -> Result<()> { + // Only BTC prices available: EUR and USD. USD->EUR must cross via BTC. + let db = Arc::new(MockDb::default()); + let rates = Arc::new(MockExchangeRate::new()); + rates.set_rate(Ticker::btc_rate("EUR")?, 100_000.0).await; // 100k EUR/BTC + rates.set_rate(Ticker::btc_rate("USD")?, 120_000.0).await; // 120k USD/BTC + let pe = PricingEngine::new(db, rates, VatClient::new()); + + // $120.00 -> BTC (0.001) -> EUR (€100.00 = 10000 cents) + let out = pe + .convert_currency( + CurrencyAmount::from_u64(Currency::USD, 12_000), + Currency::EUR, + ) + .await?; + assert_eq!(out.currency(), Currency::EUR); + assert_eq!(out.value(), 10_000); + Ok(()) + } + + #[tokio::test] + async fn convert_currency_direct_fiat_fx() -> Result<()> { + // Direct fiat FX pair EUR/USD available (as fetched from frankfurter). + let db = Arc::new(MockDb::default()); + let rates = Arc::new(MockExchangeRate::new()); + rates + .set_rate(Ticker(Currency::EUR, Currency::USD), 1.10) + .await; + let pe = PricingEngine::new(db, rates, VatClient::new()); + + // €10.00 -> $11.00 + let out = pe + .convert_currency( + CurrencyAmount::from_u64(Currency::EUR, 1_000), + Currency::USD, + ) + .await?; + assert_eq!(out.value(), 1_100); + // Inverse direction resolves too: $11.00 -> €10.00 + let back = pe + .convert_currency( + CurrencyAmount::from_u64(Currency::USD, 1_100), + Currency::EUR, + ) + .await?; + assert_eq!(back.value(), 1_000); + Ok(()) + } + + #[tokio::test] + async fn min_amount_converts_across_currencies() -> Result<()> { + // Minimum configured in EUR, charge in BTC. 1 BTC = 100,000 EUR (MOCK_RATE). + let db = MockDb::default(); + add_revolut_min_amount_config(&db, 100, "EUR").await; // €1.00 + // make_pe already registers BTC/EUR at MOCK_RATE (100,000 EUR/BTC) + let pe = make_pe(Arc::new(db)).await; + + // €1.00 at 100,000 EUR/BTC = 1e-5 BTC = 1,000,000 millisats. + assert!( + pe.enforce_min_amount( + 1, + PaymentMethod::Revolut, + CurrencyAmount::millisats(999_999) + ) + .await + .is_err() + ); + assert!( + pe.enforce_min_amount( + 1, + PaymentMethod::Revolut, + CurrencyAmount::millisats(1_000_000) + ) + .await + .is_ok() + ); + Ok(()) + } } diff --git a/lnvps_api_common/src/shasum.rs b/lnvps_api_common/src/shasum.rs index c4f611ae..8d8dc3df 100644 --- a/lnvps_api_common/src/shasum.rs +++ b/lnvps_api_common/src/shasum.rs @@ -459,7 +459,12 @@ mod tests { #[test] fn test_bare_digest_rejects_invalid() { // Not hex - assert!(parse_bare_digest_line("zz09861863ad093da0d1e97a49e4d4f57329b86b56e66e3c0578e788c4fa3c2b").is_none()); + assert!( + parse_bare_digest_line( + "zz09861863ad093da0d1e97a49e4d4f57329b86b56e66e3c0578e788c4fa3c2b" + ) + .is_none() + ); // Wrong length assert!(parse_bare_digest_line("deadbeef").is_none()); } @@ -696,7 +701,10 @@ SHA256 (file-a.iso) = 049d861863ad093da0d1e97a49e4d4f57329b86b56e66e3c0578e788c4 let result = probe_checksum_from_image_url(image_url, filename).await; let (entry, sums_url) = result.expect("should find CHECKSUM file"); - assert!(sums_url.ends_with("/CHECKSUM"), "unexpected sums_url: {sums_url}"); + assert!( + sums_url.ends_with("/CHECKSUM"), + "unexpected sums_url: {sums_url}" + ); assert_eq!(entry.algorithm, ShasumAlgorithm::Sha256); Ok(()) } diff --git a/lnvps_db/migrations/20260721102854_payment_method_min_amount.sql b/lnvps_db/migrations/20260721102854_payment_method_min_amount.sql new file mode 100644 index 00000000..6a709883 --- /dev/null +++ b/lnvps_db/migrations/20260721102854_payment_method_min_amount.sql @@ -0,0 +1,8 @@ +-- Add a minimum processable amount to payment method configs. +-- For providers with a flat base fee (e.g. Revolut's 20c) it makes no sense to +-- process very small payments, since the fee would be a large fraction of the +-- charge. `min_amount` is stored in the smallest unit of `min_amount_currency` +-- (cents for fiat, millisats for BTC), mirroring `processing_fee_base`. +ALTER TABLE payment_method_config + ADD COLUMN min_amount BIGINT UNSIGNED NULL, + ADD COLUMN min_amount_currency VARCHAR(4) NULL; diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index d75ba07e..ae6ab5cf 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -2785,6 +2785,12 @@ pub struct PaymentMethodConfig { pub processing_fee_base: Option, /// Currency for the processing fee base pub processing_fee_currency: Option, + /// Minimum processable amount in the smallest unit of `min_amount_currency` + /// (cents for fiat, millisats for BTC). `None` (or 0) means no minimum. + /// Payments whose gross total is below this are rejected for this method. + pub min_amount: Option, + /// Currency for `min_amount` + pub min_amount_currency: Option, /// Supported currency codes (e.g., "EUR", "USD", "BTC") /// Empty means use default currencies based on payment method type pub supported_currencies: CommaSeparated, @@ -2831,6 +2837,8 @@ impl PaymentMethodConfig { processing_fee_rate: None, processing_fee_base: None, processing_fee_currency: None, + min_amount: None, + min_amount_currency: None, supported_currencies: CommaSeparated::default(), created: Utc::now(), modified: Utc::now(), diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index 6482c417..75de1c2f 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -3143,8 +3143,9 @@ impl LNVpsDbBase for LNVpsDbMysql { r#" INSERT INTO payment_method_config (company_id, payment_method, name, enabled, provider_type, config, - processing_fee_rate, processing_fee_base, processing_fee_currency, supported_currencies) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + processing_fee_rate, processing_fee_base, processing_fee_currency, + min_amount, min_amount_currency, supported_currencies) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "#, ) .bind(config.company_id) @@ -3156,6 +3157,8 @@ impl LNVpsDbBase for LNVpsDbMysql { .bind(config.processing_fee_rate) .bind(config.processing_fee_base) .bind(&config.processing_fee_currency) + .bind(config.min_amount) + .bind(&config.min_amount_currency) .bind(&config.supported_currencies) .execute(&self.db) .await?; @@ -3168,7 +3171,8 @@ impl LNVpsDbBase for LNVpsDbMysql { r#" UPDATE payment_method_config SET company_id = ?, payment_method = ?, name = ?, enabled = ?, provider_type = ?, config = ?, - processing_fee_rate = ?, processing_fee_base = ?, processing_fee_currency = ?, supported_currencies = ? + processing_fee_rate = ?, processing_fee_base = ?, processing_fee_currency = ?, + min_amount = ?, min_amount_currency = ?, supported_currencies = ? WHERE id = ? "#, ) @@ -3181,6 +3185,8 @@ impl LNVpsDbBase for LNVpsDbMysql { .bind(config.processing_fee_rate) .bind(config.processing_fee_base) .bind(&config.processing_fee_currency) + .bind(config.min_amount) + .bind(&config.min_amount_currency) .bind(&config.supported_currencies) .bind(config.id) .execute(&self.db) From 5392188588ef0d535c608ce3ea381e94d2b2b1b4 Mon Sep 17 00:00:00 2001 From: Kieran Date: Tue, 21 Jul 2026 10:57:51 +0100 Subject: [PATCH 2/2] fix(exchange): drive fiat FX off company base currency, not hardcoded EUR The FX fetch hardcoded EUR as the base and a fixed currency list. Instead fetch FX anchored on each company's own base_currency, covering only the distinct billing currencies actually in use, and only when there is more than one (otherwise every conversion is fiat<->BTC, already covered by mempool prices). - fetch_fiat_fx_rates now takes (base, symbols); BTC/self are skipped - fetch_fx_for_currencies fetches every ordered pair among a currency set - the rate updater derives the set from list_companies().base_currency - removed the hardcoded EUR-base FX call from the generic fetch_rates --- API_CHANGELOG.md | 2 +- lnvps_api/src/bin/api.rs | 24 +++++++ lnvps_api_common/src/exchange.rs | 112 +++++++++++++++++++------------ 3 files changed, 94 insertions(+), 44 deletions(-) diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index 9d3cf0c0..422b9d19 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -13,7 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed -- **Exchange module: precise conversions + fiat FX rates** — currency conversion is now integer-precise (done in `f64` on smallest-unit values instead of routing through `f32`, which previously lost a unit, e.g. €1.00 → 999,999 msat instead of 1,000,000). The exchange service now also pulls fiat FX rates (base EUR, from frankfurter.app / ECB) alongside BTC prices, and the pricing engine can resolve arbitrary currency pairs directly, inverse, or via a BTC/EUR cross rate. The processing-fee base amount is now properly converted into the transaction currency instead of being used as-is. +- **Exchange module: precise conversions + fiat FX rates** — currency conversion is now integer-precise (done in `f64` on smallest-unit values instead of routing through `f32`, which previously lost a unit, e.g. €1.00 → 999,999 msat instead of 1,000,000). The exchange service now also pulls fiat FX rates (from frankfurter.app / ECB) between the distinct company billing currencies — anchored on each company's own `base_currency`, not a hardcoded base, and only when companies bill in more than one fiat currency — alongside BTC prices. The pricing engine can resolve arbitrary currency pairs directly, inverse, or via a BTC/EUR cross rate. The processing-fee base amount is now properly converted into the transaction currency instead of being used as-is. - **OS image downloads now run in parallel across hosts** — the `DownloadOsImages` worker job previously processed hosts strictly sequentially (each host waited for the previous host to finish all images). Hosts are now processed concurrently; images on a single host remain sequential to avoid saturating that host's storage backend. Affects the admin `POST /api/admin/v1/vm_os_images/{id}/download` endpoint and the periodic image check. - **DNS is now best-effort during VM provisioning** — forward (A/AAAA) and reverse (PTR) DNS records are convenience only and no longer block or roll back a deploy. Previously a reverse-DNS failure (notably OVH rejecting a PTR with a `4xx` until the forward name resolves — a fatal, non-retried error) tore down the whole pipeline and destroyed the freshly-created VM. Now such failures are logged and the deploy proceeds; the VM keeps its IPs and MAC. Any records that failed to create are reconciled automatically on the periodic VM check (and can still be forced via the existing DNS patch job). diff --git a/lnvps_api/src/bin/api.rs b/lnvps_api/src/bin/api.rs index 86930f9c..e742cb43 100644 --- a/lnvps_api/src/bin/api.rs +++ b/lnvps_api/src/bin/api.rs @@ -303,8 +303,10 @@ async fn main() -> Result<(), Error> { // refresh rates every 1min let rates = exchange.clone(); + let rates_db = db.clone(); tasks.push(tokio::spawn(async move { loop { + // BTC prices (mempool.space) for all supported fiat currencies match rates.fetch_rates().await { Ok(z) => { for r in z { @@ -313,6 +315,28 @@ async fn main() -> Result<(), Error> { } Err(e) => error!("Failed to fetch rates: {}", e), } + + // Fiat FX rates between the distinct company billing currencies. + // Only needed when companies bill in more than one fiat currency; + // otherwise every conversion is fiat<->BTC (covered above). + match rates_db.list_companies().await { + Ok(companies) => { + let mut bases: Vec = companies + .iter() + .filter_map(|c| c.base_currency.parse().ok()) + .filter(|c| *c != payments_rs::currency::Currency::BTC) + .collect(); + bases.sort_by_key(|c| c.to_string()); + bases.dedup(); + if bases.len() > 1 { + for r in lnvps_api_common::fetch_fx_for_currencies(&bases).await { + rates.set_rate(r.ticker, r.rate).await; + } + } + } + Err(e) => error!("Failed to list companies for FX rates: {}", e), + } + tokio::time::sleep(Duration::from_secs(120)).await; } })); diff --git a/lnvps_api_common/src/exchange.rs b/lnvps_api_common/src/exchange.rs index c7c0cf18..e43c57b2 100644 --- a/lnvps_api_common/src/exchange.rs +++ b/lnvps_api_common/src/exchange.rs @@ -158,34 +158,31 @@ pub fn alt_prices(rates: &Vec, source: CurrencyAmount) -> Vecfiat pairs can be resolved (directly or via a EUR cross). -pub async fn fetch_fiat_fx_rates() -> Result> { - let symbols = FX_CURRENCIES +/// Fetch fiat FX rates for `base` against each of `symbols` from frankfurter.app +/// (ECB reference rates, free, no API key). Returns `Ticker(base, X)` rates +/// where the value is the amount of `X` per 1 unit of `base`. +/// +/// The `base` is the caller's currency of interest (a company's billing +/// currency), never a hardcoded value. BTC is skipped — BTC prices come from +/// mempool.space, not an FX feed. +pub async fn fetch_fiat_fx_rates(base: Currency, symbols: &[Currency]) -> Result> { + let symbol_list = symbols .iter() + .filter(|c| **c != base && **c != Currency::BTC) .map(|c| c.to_string()) .collect::>() .join(","); - let url = format!("https://api.frankfurter.app/latest?base=EUR&symbols={symbols}"); + if base == Currency::BTC || symbol_list.is_empty() { + return Ok(vec![]); + } + let url = format!("https://api.frankfurter.app/latest?base={base}&symbols={symbol_list}"); let rsp = reqwest::get(&url).await?.text().await?; let parsed: FrankfurterRates = serde_json::from_str(&rsp)?; let mut ret = Vec::new(); - for cur in FX_CURRENCIES { + for cur in symbols { if let Some(rate) = parsed.rates.get(&cur.to_string()) { ret.push(TickerRate { - ticker: Ticker(Currency::EUR, *cur), + ticker: Ticker(base, *cur), rate: *rate, }); } @@ -193,6 +190,21 @@ pub async fn fetch_fiat_fx_rates() -> Result> { Ok(ret) } +/// Fetch fiat FX rates covering every ordered pair among `currencies` (each +/// currency as a base against all the others). Errors for individual bases are +/// logged and skipped. Use this to keep direct rates between the set of +/// currencies actually in use (e.g. distinct company billing currencies). +pub async fn fetch_fx_for_currencies(currencies: &[Currency]) -> Vec { + let mut ret = Vec::new(); + for base in currencies { + match fetch_fiat_fx_rates(*base, currencies).await { + Ok(mut fx) => ret.append(&mut fx), + Err(e) => error!("Failed to fetch fiat FX for base {}: {}", base, e), + } + } + ret +} + #[derive(Clone, Default)] pub struct InMemoryRateCache { cache: Arc>>, @@ -251,12 +263,6 @@ impl ExchangeRateService for InMemoryRateCache { }); } - // Also pull fiat FX (base EUR) so fiat<->fiat pairs are resolvable. - match fetch_fiat_fx_rates().await { - Ok(mut fx) => ret.append(&mut fx), - Err(e) => error!("Failed to fetch fiat FX rates: {}", e), - } - Ok(ret) } @@ -396,24 +402,7 @@ impl RedisExchangeRateService { .await?; } - // Also pull fiat FX (base EUR) so fiat<->fiat pairs are resolvable. - match fetch_fiat_fx_rates().await { - Ok(fx) => { - for ticker_rate in fx { - let _: () = conn - .set_ex( - self.ticker_key(&ticker_rate.ticker), - ticker_rate.rate, - self.cache_ttl.as_secs(), - ) - .await?; - ret.push(ticker_rate); - } - } - Err(e) => error!("Failed to fetch fiat FX rates: {}", e), - } - - trace!("Fetched and cached {} rates", ret.len()); + trace!("Fetched and cached {} rates from mempool.space", ret.len()); Ok(ret) } } @@ -520,6 +509,43 @@ mod tests { assert!(f.can_convert(Currency::BTC)); } + #[tokio::test] + async fn fx_fetch_noop_cases() { + // No symbols -> no request, empty result + assert!( + fetch_fiat_fx_rates(Currency::USD, &[]) + .await + .unwrap() + .is_empty() + ); + // Only self / BTC symbols -> filtered out -> empty + assert!( + fetch_fiat_fx_rates(Currency::EUR, &[Currency::EUR, Currency::BTC]) + .await + .unwrap() + .is_empty() + ); + // BTC base is never fetched via FX + assert!( + fetch_fiat_fx_rates(Currency::BTC, &[Currency::USD]) + .await + .unwrap() + .is_empty() + ); + // A single currency has no pairs to fetch + assert!(fetch_fx_for_currencies(&[Currency::EUR]).await.is_empty()); + } + + #[tokio::test] + async fn fx_fetch_real_pair_uses_requested_base() -> Result<()> { + // Hits frankfurter.app (ECB). base=EUR must yield a Ticker(EUR, USD). + let rates = fetch_fiat_fx_rates(Currency::EUR, &[Currency::USD]).await?; + assert_eq!(rates.len(), 1); + assert_eq!(rates[0].ticker, Ticker(Currency::EUR, Currency::USD)); + assert!(rates[0].rate > 0.0); + Ok(()) + } + /// Regression: conversions must be integer-precise, not lose a unit to f32. #[test] fn convert_precise_no_f32_loss() {