Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions ADMIN_API_ENDPOINTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
```

Expand Down Expand Up @@ -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)
}
```

Expand Down Expand Up @@ -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)",
Expand Down Expand Up @@ -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"])
}
Expand Down
2 changes: 2 additions & 0 deletions API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
2 changes: 2 additions & 0 deletions API_DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
```

Expand Down
9 changes: 9 additions & 0 deletions lnvps_api/src/api/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

/// 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<u64>,

/// Currency for `min_amount`
#[serde(skip_serializing_if = "Option::is_none")]
pub min_amount_currency: Option<String>,
}

/// Payment data related to the payment method
Expand Down
8 changes: 8 additions & 0 deletions lnvps_api/src/api/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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,
Expand All @@ -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,
});
}

Expand Down Expand Up @@ -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(),
),
Expand Down
24 changes: 24 additions & 0 deletions lnvps_api/src/bin/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<payments_rs::currency::Currency> = 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;
}
}));
Expand Down
5 changes: 2 additions & 3 deletions lnvps_api/src/data_migration/payment_method_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions lnvps_api/src/subscription/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
35 changes: 35 additions & 0 deletions lnvps_api_admin/src/admin/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3698,6 +3698,11 @@ pub struct AdminPaymentMethodConfigInfo {
pub processing_fee_base: Option<u64>,
/// Currency for the processing fee base
pub processing_fee_currency: Option<String>,
/// 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<u64>,
/// Currency for the minimum amount
pub min_amount_currency: Option<String>,
/// Supported currency codes (e.g., ["EUR", "USD"])
pub supported_currencies: Vec<String>,
pub created: DateTime<Utc>,
Expand All @@ -3720,6 +3725,8 @@ impl From<lnvps_db::PaymentMethodConfig> 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,
Expand All @@ -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<u64>,
pub processing_fee_currency: Option<String>,
/// Minimum processable amount in smallest currency units (cents for fiat,
/// millisats for BTC)
pub min_amount: Option<u64>,
pub min_amount_currency: Option<String>,
/// Supported currency codes (e.g., ["EUR", "USD"])
pub supported_currencies: Option<Vec<String>>,
}
Expand All @@ -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(),
Expand All @@ -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(),
Expand Down Expand Up @@ -3803,6 +3826,18 @@ pub struct UpdatePaymentMethodConfigRequest {
deserialize_with = "lnvps_api_common::deserialize_nullable_option"
)]
pub processing_fee_currency: Option<Option<String>>,
/// 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<Option<u64>>,
#[serde(
default,
deserialize_with = "lnvps_api_common::deserialize_nullable_option"
)]
pub min_amount_currency: Option<Option<String>>,
/// Supported currency codes (e.g., ["EUR", "USD"])
pub supported_currencies: Option<Vec<String>>,
}
Expand Down
25 changes: 25 additions & 0 deletions lnvps_api_admin/src/admin/payment_methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading