diff --git a/ADMIN_API_ENDPOINTS.md b/ADMIN_API_ENDPOINTS.md index 5dbb86e..a3e7385 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 41171a4..422b9d1 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 (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/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index bc7c81c..239bd0f 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 f35ab51..5d5286a 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 8199284..a64bd7a 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/bin/api.rs b/lnvps_api/src/bin/api.rs index 86930f9..e742cb4 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/src/data_migration/payment_method_config.rs b/lnvps_api/src/data_migration/payment_method_config.rs index bbc8e3f..7523f3b 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 88e5b1a..af3ba5a 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 868feaa..17a1d87 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 b528593..a5d6af5 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 f6256e9..e43c57b 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,53 @@ pub fn alt_prices(rates: &Vec, source: CurrencyAmount) -> Vec Result> { + let symbol_list = symbols + .iter() + .filter(|c| **c != base && **c != Currency::BTC) + .map(|c| c.to_string()) + .collect::>() + .join(","); + 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 symbols { + if let Some(rate) = parsed.rates.get(&cur.to_string()) { + ret.push(TickerRate { + ticker: Ticker(base, *cur), + rate: *rate, + }); + } + } + 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>>, @@ -399,6 +459,12 @@ impl ExchangeRateService for RedisExchangeRateService { } } +#[derive(Deserialize)] +struct FrankfurterRates { + #[serde(default)] + pub rates: HashMap, +} + #[derive(Deserialize)] struct MempoolRates { #[serde(rename = "USD")] @@ -425,23 +491,78 @@ 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)); } + + #[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() { + 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 8344057..91e1da0 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 c4f611a..8d8dc3d 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 0000000..6a70988 --- /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 d75ba07..ae6ab5c 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 6482c41..75de1c2 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)