From cd2688a71331ae31bbdce2e11ec6125ea34828d3 Mon Sep 17 00:00:00 2001 From: Kieran Date: Tue, 21 Jul 2026 13:35:23 +0100 Subject: [PATCH 1/3] feat(payments): source payment providers from database, drop YAML config (#182) Payment providers (Lightning node, on-chain wallet, Revolut) are now built from the per-company `payment_method_config` rows via `PaymentMethodFactory` at startup, instead of the YAML `[lightning]`/`[revolut]` settings. The DB is now the sole source of truth: - Remove `Settings.lightning`/`Settings.revolut`, the `LightningConfig` enum, and `Settings::get_node`/`get_onchain`/`get_revolut`. - Remove the one-time YAML->DB payment_method_config seed migration. - Startup fails with a clear error if no enabled Lightning/on-chain config exists for the default (first) company; providers are configured via the admin API from now on. - On-chain provider now honours the DB `min_confirmations`/`address_type`/ `account` fields (previously hardcoded to 1 / p2wkh / default account). - Route the Revolut/Stripe settlement listeners through the factory. - `SubscriptionHandler::new` now takes the Revolut service as a parameter. The runtime still resolves a single global provider set from the default company; true per-company provider routing remains future work. --- API_CHANGELOG.md | 1 + lnvps_api/config.yaml | 9 +- lnvps_api/src/bin/api.rs | 57 +++- lnvps_api/src/data_migration/mod.rs | 8 - .../data_migration/payment_method_config.rs | 263 ------------------ lnvps_api/src/dvm/lnvps.rs | 1 + lnvps_api/src/payments/invoice.rs | 3 + lnvps_api/src/payments/mod.rs | 15 +- lnvps_api/src/payments/onchain.rs | 1 + lnvps_api/src/provisioner/vm.rs | 3 + lnvps_api/src/settings.rs | 100 ------- lnvps_api/src/subscription/mod.rs | 14 +- lnvps_api/src/worker.rs | 1 + 13 files changed, 91 insertions(+), 385 deletions(-) delete mode 100644 lnvps_api/src/data_migration/payment_method_config.rs diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index fcd2d05..813ef27 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed +- **Payment providers are now configured exclusively in the database** (issue #182) — the Lightning node, on-chain provider and Revolut service used at startup are now constructed from the per-company `payment_method_config` rows (via `PaymentMethodFactory`). The YAML `lightning:` / `revolut:` config sections have been **removed**, along with the one-time YAML→DB seed migration and the `Settings::get_node`/`get_onchain`/`get_revolut` helpers. Providers must be configured via the admin API (`POST/PATCH /api/admin/v1/payment_method_configs`); startup now fails with a clear error if no enabled Lightning/on-chain config exists for the default company. The on-chain provider now honours the DB `min_confirmations`, `address_type` and `account` fields (previously hardcoded to 1 / p2wkh / default account). The runtime still resolves a single global provider set from the default (first) company; true per-company provider routing remains future work. **Operators must ensure their `payment_method_config` rows are seeded before upgrading** (deployments that ran the previous seed migration already are). No API surface change. - **Custom VM price quote now returns converted prices** — `POST /api/v1/vm/custom-template/price` now returns `{ currency, amount, other_price }`, where `other_price` lists the same quote converted into the other supported currencies (matching the template listing's `cost_plan.other_price`). Previously it returned only the single base-currency `{ currency, amount }`; existing clients reading those two fields are unaffected. - **Currency conversion (`alt_prices`) de-duplicates per target currency** — the price-conversion helper behind every `other_price` list (templates, custom pricing quote, IP-space pricing) no longer emits duplicate entries for the same currency. When both a direct fiat FX rate (e.g. `EUR/USD`) and the BTC round-trip (`EUR→BTC→USD`) exist, it now keeps a single entry and **prefers the direct fiat FX rate** (ECB is more accurate for fiat↔fiat than hopping through BTC). Single-currency deployments (no direct fiat FX rates) are unchanged. - **Renewals are now bounded by a maximum prepay window** — a renewal is rejected (with a clear error) once it would push a subscription's expiry beyond `now + max_prepay_days`. This bounds both an oversized single `?intervals=N` request and repeated back-to-back renewals (once expiry is ~the window out, further renewals are rejected until real time passes). Affects `POST /api/v1/vm/{id}/renew` and `POST /api/v1/subscriptions/{id}/renew`. The limit is configured per company (`max_prepay_days` on `POST`/`PATCH /api/admin/v1/companies`, also returned on company info; `0` inherits the global default) with a global fallback (`max-prepay-days` in service config, default 365 days). It composes with host sunsetting — the effective ceiling is the earlier of the sunset date and the prepay window. The effective window is also surfaced to clients on the user-facing VM status (`GET /api/v1/vm/{id}` and `GET /api/v1/vms`) as `max_prepay_days`, so the UI can cap the renewal interval selector to what the server will accept. diff --git a/lnvps_api/config.yaml b/lnvps_api/config.yaml index 0c92e19..dbca395 100644 --- a/lnvps_api/config.yaml +++ b/lnvps_api/config.yaml @@ -1,9 +1,8 @@ db: "mysql://root:root@localhost:3376/lnvps" -lightning: - lnd: - url: "https://127.0.0.1:10003" - cert: "/home/kieran/.polar/networks/1/volumes/lnd/alice/tls.cert" - macaroon: "/home/kieran/.polar/networks/1/volumes/lnd/alice/data/chain/bitcoin/regtest/admin.macaroon" +# Payment providers (Lightning node, on-chain wallet, Revolut, ...) are no +# longer configured here. They are stored in the database `payment_method_config` +# table (per company) and managed via the admin API +# (POST/PATCH /api/admin/v1/payment_method_configs). delete-after: 3 # Global default maximum prepay/renewal window in days. A renewal is rejected # once it would push a subscription's expiry beyond now + this many days. Used diff --git a/lnvps_api/src/bin/api.rs b/lnvps_api/src/bin/api.rs index e742cb4..c26fcce 100644 --- a/lnvps_api/src/bin/api.rs +++ b/lnvps_api/src/bin/api.rs @@ -3,6 +3,7 @@ use clap::{Parser, ValueEnum}; use config::{Config, File}; use lnvps_api::data_migration::run_data_migrations; use lnvps_api::dvm::start_dvms; +use lnvps_api::payment_factory::PaymentMethodFactory; use lnvps_api::payments::listen_all_payments; use lnvps_api::settings::Settings; use lnvps_api::worker::Worker; @@ -162,8 +163,59 @@ async fn main() -> Result<(), Error> { }; let exchange = make_exchange_service(&settings.redis); - let node = settings.get_node().await?; - let onchain = settings.get_onchain().await?; + + // Payment providers are sourced exclusively from the database + // `payment_method_config` rows (per-company config). There is no YAML + // fallback: providers must be configured in the database (via the admin + // API), otherwise startup fails. + // + // NOTE: the runtime still uses a single *global* node/on-chain provider + // selected from the default (first) company. True per-company provider + // routing is tracked as future work (see issue #182); the data model + // already carries `company_id` end-to-end, but the settlement handlers and + // SubscriptionHandler resolve one provider set for the whole process. + let factory = PaymentMethodFactory::new(db.clone()); + let default_company_id = db + .list_companies() + .await? + .into_iter() + .next() + .map(|c| c.id) + .ok_or_else(|| { + Error::msg("No companies found in database; cannot resolve payment providers") + })?; + + let node = factory + .get_lightning_node_for_company(default_company_id) + .await? + .ok_or_else(|| { + Error::msg(format!( + "No enabled Lightning payment_method_config for company {default_company_id}; \ + configure one via the admin API" + )) + })?; + info!("Using Lightning node from payment_method_config (company {default_company_id})"); + + #[cfg(feature = "onchain")] + let onchain = factory + .get_onchain_provider_for_company(default_company_id) + .await? + .ok_or_else(|| { + Error::msg(format!( + "No enabled on-chain payment_method_config for company {default_company_id}; \ + configure one via the admin API" + )) + })?; + #[cfg(feature = "onchain")] + info!("Using on-chain provider from payment_method_config (company {default_company_id})"); + + // The global Revolut service (if any) used by SubscriptionHandler for + // saved-card / off-session charges. Absent when no company has an enabled + // Revolut config. + let revolut = factory + .get_revolut_for_company(default_company_id) + .await + .unwrap_or(None); // Optional IP -> country geolocation for VAT place-of-supply evidence. let geoip: Option> = match &settings.geoip_database { @@ -225,6 +277,7 @@ async fn main() -> Result<(), Error> { db.clone(), node.clone(), onchain.clone(), + revolut.clone(), exchange.clone(), vat.clone(), work_commander.clone(), diff --git a/lnvps_api/src/data_migration/mod.rs b/lnvps_api/src/data_migration/mod.rs index 660a27a..560142c 100644 --- a/lnvps_api/src/data_migration/mod.rs +++ b/lnvps_api/src/data_migration/mod.rs @@ -4,7 +4,6 @@ use crate::data_migration::email_hash_backfill::EmailHashBackfillMigration; use crate::data_migration::encryption_migration::EncryptionDataMigration; use crate::data_migration::ip6_init::Ip6InitDataMigration; use crate::data_migration::orphaned_custom_templates::OrphanedCustomTemplatesMigration; -use crate::data_migration::payment_method_config::PaymentMethodConfigMigration; use crate::data_migration::purge_never_paid_deleted_vms::PurgeNeverPaidDeletedVmsMigration; use crate::data_migration::ssh_key_migration::SshKeyMigration; use crate::provisioner::VmProvisioner; @@ -22,7 +21,6 @@ mod email_hash_backfill; mod encryption_migration; mod ip6_init; mod orphaned_custom_templates; -mod payment_method_config; mod purge_never_paid_deleted_vms; mod ssh_key_migration; @@ -57,12 +55,6 @@ pub async fn run_data_migrations( migrations.push(Box::new(ArpRefFixerDataMigration::new(db.clone()))); - // Migrate payment method config from YAML to database - migrations.push(Box::new(PaymentMethodConfigMigration::new( - db.clone(), - settings.clone(), - ))); - // Migrate SSH key from proxmox config to database migrations.push(Box::new(SshKeyMigration::new(db.clone(), settings.clone()))); diff --git a/lnvps_api/src/data_migration/payment_method_config.rs b/lnvps_api/src/data_migration/payment_method_config.rs deleted file mode 100644 index 7523f3b..0000000 --- a/lnvps_api/src/data_migration/payment_method_config.rs +++ /dev/null @@ -1,263 +0,0 @@ -use crate::data_migration::DataMigration; -use crate::settings::{LightningConfig, Settings}; -use anyhow::Result; -use lnvps_db::{ - BitvoraConfig, LNVpsDb, LndConfig, OnChainAddressType, OnChainProviderConfig, PaymentMethod, - PaymentMethodConfig, ProviderConfig, RevolutProviderConfig, -}; -use log::info; -use std::future::Future; -use std::path::PathBuf; -use std::pin::Pin; -use std::sync::Arc; - -pub struct PaymentMethodConfigMigration { - db: Arc, - settings: Settings, -} - -impl PaymentMethodConfigMigration { - pub fn new(db: Arc, settings: Settings) -> Self { - Self { db, settings } - } -} - -impl DataMigration for PaymentMethodConfigMigration { - fn name(&self) -> &'static str { - "payment method config migration" - } - - fn migrate(&self) -> Pin> + Send>> { - let db = self.db.clone(); - let settings = self.settings.clone(); - Box::pin(async move { - info!("Starting payment method config migration from YAML settings"); - - // Skip per payment *method*, not globally: earlier versions of - // this migration only imported the Lightning config, so a global - // "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 need_lightning = !has_method(PaymentMethod::Lightning); - // On-chain is only seeded from an LND wallet - let need_onchain = matches!(settings.lightning, LightningConfig::LND { .. }) - && !has_method(PaymentMethod::OnChain); - let need_revolut = settings.revolut.is_some() && !has_method(PaymentMethod::Revolut); - - if !need_lightning && !need_onchain && !need_revolut { - return Ok(format!( - "configs already exist ({} found), skipped", - existing_configs.len() - )); - } - - // Get the first company to assign configs to - let companies = db.list_companies().await?; - let company = companies.first().ok_or_else(|| { - anyhow::anyhow!( - "No companies found in database, cannot migrate payment method configs" - ) - })?; - let company_id = company.id; - info!( - "Using company '{}' (id={}) for payment method config migration", - company.name, company_id - ); - - let mut migrated_count = 0; - - // Migrate Lightning config - match &settings.lightning { - LightningConfig::LND { - url, - cert, - macaroon, - } => { - if need_lightning { - let provider_config = ProviderConfig::Lnd(LndConfig { - url: url.clone(), - cert_path: PathBuf::from(cert), - macaroon_path: PathBuf::from(macaroon), - }); - - let payment_config = PaymentMethodConfig::new_with_config( - company_id, - PaymentMethod::Lightning, - "LND Node".to_string(), - true, - provider_config, - ); - - db.insert_payment_method_config(&payment_config).await?; - info!("Migrated LND Lightning config for company {}", company_id); - migrated_count += 1; - } - - if need_onchain { - // On-chain payments reuse the same LND wallet; seed a - // config with defaults so it is manageable in the admin UI - let onchain_config = ProviderConfig::OnChain(OnChainProviderConfig { - url: url.clone(), - cert_path: PathBuf::from(cert), - macaroon_path: PathBuf::from(macaroon), - address_type: OnChainAddressType::default(), - account: None, - min_confirmations: 1, - }); - let payment_config = PaymentMethodConfig::new_with_config( - company_id, - PaymentMethod::OnChain, - "LND On-chain".to_string(), - true, - onchain_config, - ); - db.insert_payment_method_config(&payment_config).await?; - info!("Migrated LND on-chain config for company {}", company_id); - migrated_count += 1; - } - } - LightningConfig::Bitvora { - token, - webhook_secret, - } => { - if need_lightning { - let provider_config = ProviderConfig::Bitvora(BitvoraConfig { - token: token.clone(), - webhook_secret: webhook_secret.clone(), - }); - - let payment_config = PaymentMethodConfig::new_with_config( - company_id, - PaymentMethod::Lightning, - "Bitvora".to_string(), - true, - provider_config, - ); - - db.insert_payment_method_config(&payment_config).await?; - info!( - "Migrated Bitvora Lightning config for company {}", - company_id - ); - migrated_count += 1; - } - } - } - - // Migrate Revolut config if present - if need_revolut && let Some(ref revolut) = settings.revolut { - let provider_config = ProviderConfig::Revolut(RevolutProviderConfig { - url: revolut - .url - .clone() - .unwrap_or_else(|| "https://api.revolut.com".to_string()), - token: revolut.token.clone(), - api_version: revolut.api_version.clone(), - public_key: revolut.public_key.clone(), - webhook_secret: None, // Will be populated when webhook is registered - }); - - let payment_config = PaymentMethodConfig::new_with_config( - company_id, - PaymentMethod::Revolut, - "Revolut".to_string(), - true, - provider_config, - ); - - db.insert_payment_method_config(&payment_config).await?; - info!("Migrated Revolut config for company {}", company_id); - migrated_count += 1; - } - - Ok(format!( - "migrated {migrated_count} payment method config(s) for company {company_id}" - )) - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::settings::mock_settings; - use lnvps_api_common::MockDb; - use lnvps_db::LNVpsDbBase; - - fn lightning_only_config(company_id: u64) -> PaymentMethodConfig { - PaymentMethodConfig::new_with_config( - company_id, - PaymentMethod::Lightning, - "LND Node".to_string(), - true, - ProviderConfig::Lnd(LndConfig { - url: "https://127.0.0.1:10009".to_string(), - cert_path: PathBuf::from("tls.cert"), - macaroon_path: PathBuf::from("admin.macaroon"), - }), - ) - } - - #[tokio::test] - async fn test_fresh_db_imports_lightning_and_onchain() -> Result<()> { - let db = Arc::new(MockDb::default()); - let migration = PaymentMethodConfigMigration::new(db.clone(), mock_settings()); - - migration.migrate().await?; - - let configs = db.list_payment_method_configs().await?; - assert!( - configs - .iter() - .any(|c| c.payment_method == PaymentMethod::Lightning) - ); - assert!( - configs - .iter() - .any(|c| c.payment_method == PaymentMethod::OnChain) - ); - Ok(()) - } - - /// Regression: a previously imported Lightning config must not prevent - /// the newer on-chain config from being imported. - #[tokio::test] - async fn test_existing_lightning_does_not_block_onchain_import() -> Result<()> { - let db = Arc::new(MockDb::default()); - db.insert_payment_method_config(&lightning_only_config(1)) - .await?; - - let migration = PaymentMethodConfigMigration::new(db.clone(), mock_settings()); - let result = migration.migrate().await?; - assert!(result.contains("migrated 1"), "unexpected result: {result}"); - - let configs = db.list_payment_method_configs().await?; - let lightning_count = configs - .iter() - .filter(|c| c.payment_method == PaymentMethod::Lightning) - .count(); - let onchain_count = configs - .iter() - .filter(|c| c.payment_method == PaymentMethod::OnChain) - .count(); - assert_eq!(lightning_count, 1, "must not duplicate lightning config"); - assert_eq!(onchain_count, 1, "on-chain config must be imported"); - Ok(()) - } - - #[tokio::test] - async fn test_all_present_skips() -> Result<()> { - let db = Arc::new(MockDb::default()); - let migration = PaymentMethodConfigMigration::new(db.clone(), mock_settings()); - migration.migrate().await?; - - // Second run must be a no-op - let result = migration.migrate().await?; - assert!(result.contains("skipped"), "unexpected result: {result}"); - assert_eq!(db.list_payment_method_configs().await?.len(), 2); - Ok(()) - } -} diff --git a/lnvps_api/src/dvm/lnvps.rs b/lnvps_api/src/dvm/lnvps.rs index 4e1af81..d19aa23 100644 --- a/lnvps_api/src/dvm/lnvps.rs +++ b/lnvps_api/src/dvm/lnvps.rs @@ -228,6 +228,7 @@ mod tests { db.clone(), node.clone(), Arc::new(MockOnChainProvider::default()), + None, exch.clone(), lnvps_api_common::VatClient::new(), Arc::new(ChannelWorkCommander::new()), diff --git a/lnvps_api/src/payments/invoice.rs b/lnvps_api/src/payments/invoice.rs index 36c5813..8df780e 100644 --- a/lnvps_api/src/payments/invoice.rs +++ b/lnvps_api/src/payments/invoice.rs @@ -219,6 +219,7 @@ mod tests { db.clone(), node.clone(), Arc::new(MockOnChainProvider::default()), + None, Arc::new(MockExchangeRate::default()), lnvps_api_common::VatClient::new(), Arc::new(ChannelWorkCommander::new()), @@ -373,6 +374,7 @@ mod tests { db.clone(), node.clone(), Arc::new(MockOnChainProvider::default()), + None, Arc::new(MockExchangeRate::default()), lnvps_api_common::VatClient::new(), Arc::new(ChannelWorkCommander::new()), @@ -549,6 +551,7 @@ mod tests { db.clone(), node.clone(), Arc::new(MockOnChainProvider::default()), + None, rates, lnvps_api_common::VatClient::new(), Arc::new(ChannelWorkCommander::new()), diff --git a/lnvps_api/src/payments/mod.rs b/lnvps_api/src/payments/mod.rs index db8ff4d..e6268ad 100644 --- a/lnvps_api/src/payments/mod.rs +++ b/lnvps_api/src/payments/mod.rs @@ -1,3 +1,5 @@ +#[cfg(any(feature = "revolut", feature = "stripe"))] +use crate::payment_factory::PaymentMethodFactory; use crate::payments::invoice::NodeInvoiceHandler; use crate::payments::onchain::OnChainPaymentHandler; use crate::settings::Settings; @@ -52,13 +54,18 @@ pub async fn listen_all_payments( } })); + // Fiat settlement listeners load their per-company configs through the + // PaymentMethodFactory so all payment-config access goes through one place. + #[cfg(any(feature = "revolut", feature = "stripe"))] + let factory = PaymentMethodFactory::new(db.clone()); + #[cfg(feature = "revolut")] { use crate::payments::revolut::RevolutPaymentHandler; // Load all Revolut payment configs from database - let revolut_configs = db - .list_payment_method_configs() + let revolut_configs = factory + .load_configs() .await? .into_iter() .filter(|c| c.payment_method == PaymentMethod::Revolut && c.enabled) @@ -99,8 +106,8 @@ pub async fn listen_all_payments( { use crate::payments::stripe::StripePaymentHandler; - let stripe_configs = db - .list_payment_method_configs() + let stripe_configs = factory + .load_configs() .await? .into_iter() .filter(|c| c.payment_method == PaymentMethod::Stripe && c.enabled) diff --git a/lnvps_api/src/payments/onchain.rs b/lnvps_api/src/payments/onchain.rs index a9fef1a..2430f37 100644 --- a/lnvps_api/src/payments/onchain.rs +++ b/lnvps_api/src/payments/onchain.rs @@ -583,6 +583,7 @@ mod tests { db.clone(), node, provider.clone(), + None, rates.clone(), lnvps_api_common::VatClient::new(), Arc::new(ChannelWorkCommander::new()), diff --git a/lnvps_api/src/provisioner/vm.rs b/lnvps_api/src/provisioner/vm.rs index e01c39e..62261d0 100644 --- a/lnvps_api/src/provisioner/vm.rs +++ b/lnvps_api/src/provisioner/vm.rs @@ -1141,6 +1141,7 @@ mod tests { db.clone(), node.clone(), Arc::new(MockOnChainProvider::default()), + None, rates.clone(), lnvps_api_common::VatClient::new(), wrk.clone(), @@ -1367,6 +1368,7 @@ mod tests { db.clone(), node, Arc::new(MockOnChainProvider::default()), + None, rates, lnvps_api_common::VatClient::new(), Arc::new(ChannelWorkCommander::new()), @@ -2653,6 +2655,7 @@ mod tests { db.clone(), node, Arc::new(MockOnChainProvider::default()), + None, rates, lnvps_api_common::VatClient::new(), wrk, diff --git a/lnvps_api/src/settings.rs b/lnvps_api/src/settings.rs index 82dec20..595e2f8 100644 --- a/lnvps_api/src/settings.rs +++ b/lnvps_api/src/settings.rs @@ -1,11 +1,6 @@ -use anyhow::Result; use lnvps_api_common::RedisConfig; -use payments_rs::fiat::FiatPaymentService; -use payments_rs::lightning::LightningNode; -use payments_rs::onchain::OnChainProvider; use serde::{Deserialize, Serialize}; use std::path::PathBuf; -use std::sync::Arc; #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] @@ -19,9 +14,6 @@ pub struct Settings { /// Public URL mapping to this service pub public_url: String, - /// Lightning node config for creating LN payments - pub lightning: LightningConfig, - /// Readonly mode, don't spawn any VM's pub read_only: bool, @@ -55,9 +47,6 @@ pub struct Settings { /// WhatsApp Cloud API config for sending notifications pub whatsapp: Option, - /// Config for accepting revolut payments - pub revolut: Option, - /// public host of lnvps_nostr service pub nostr_address_host: Option, @@ -376,22 +365,6 @@ pub enum CaptchaConfig { Turnstile { secret_key: String }, } -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum LightningConfig { - #[serde(rename = "lnd")] - LND { - url: String, - cert: PathBuf, - macaroon: PathBuf, - }, - #[serde(rename_all = "kebab-case")] - Bitvora { - token: String, - webhook_secret: String, - }, -} - #[derive(Debug, Clone, Deserialize, Serialize)] pub struct NostrConfig { pub relays: Vec, @@ -567,73 +540,6 @@ pub struct EncryptionConfig { pub auto_generate: bool, } -impl Settings { - pub fn get_revolut(&self) -> Result>> { - match &self.revolut { - #[cfg(feature = "revolut")] - Some(c) => Ok(Some(Arc::new(payments_rs::fiat::RevolutApi::new( - c.clone(), - )?))), - _ => Ok(None), - } - } - - pub async fn get_node(&self) -> Result> { - match &self.lightning { - #[cfg(feature = "lnd")] - LightningConfig::LND { - url, - cert, - macaroon, - } => Ok(Arc::new( - payments_rs::lightning::LndNode::new(url, cert, macaroon).await?, - )), - #[cfg(feature = "bitvora")] - LightningConfig::Bitvora { - token, - webhook_secret, - } => Ok(Arc::new(payments_rs::lightning::BitvoraNode::new( - token, - webhook_secret, - "/api/v1/webhook/bitvora", - ))), - _ => anyhow::bail!("Unsupported lightning config!"), - } - } - - /// Build the on-chain payment provider from the configured LND node. - /// - /// On-chain payments reuse the same LND wallet as the Lightning node, so - /// this is required in the same way [`Self::get_node`] is: any backend - /// that cannot receive on-chain payments is an error. - pub async fn get_onchain(&self) -> Result> { - match &self.lightning { - #[cfg(feature = "onchain")] - LightningConfig::LND { - url, - cert, - macaroon, - } => { - use payments_rs::onchain::{LndAddressType, LndOnChainConfig, LndOnChainProvider}; - Ok(Arc::new( - LndOnChainProvider::new( - url, - cert, - macaroon, - LndOnChainConfig { - address_type: LndAddressType::WitnessPubkeyHash, - account: None, - min_confirmations: 1, - }, - ) - .await?, - )) - } - _ => anyhow::bail!("Unsupported lightning config for on-chain payments!"), - } - } -} - /// Default global maximum prepay window (days) when unspecified in config. pub fn default_max_prepay_days() -> u16 { 365 @@ -645,11 +551,6 @@ pub fn mock_settings() -> Settings { listen: None, db: "".to_string(), public_url: "http://localhost:8000".to_string(), - lightning: LightningConfig::LND { - url: "".to_string(), - cert: Default::default(), - macaroon: Default::default(), - }, read_only: false, provisioner: ProvisionerConfig { proxmox: Some(ProxmoxConfig { @@ -677,7 +578,6 @@ pub fn mock_settings() -> Settings { nostr: None, telegram: None, whatsapp: None, - revolut: None, nostr_address_host: None, redis: None, encryption: None, diff --git a/lnvps_api/src/subscription/mod.rs b/lnvps_api/src/subscription/mod.rs index bdb3707..8d0063b 100644 --- a/lnvps_api/src/subscription/mod.rs +++ b/lnvps_api/src/subscription/mod.rs @@ -121,11 +121,13 @@ pub struct SubscriptionHandler { } impl SubscriptionHandler { + #[allow(clippy::too_many_arguments)] pub fn new( settings: Settings, db: Arc, node: Arc, onchain: Arc, + revolut: Option>, rates: Arc, vat: VatClient, tx: Arc, @@ -133,7 +135,7 @@ impl SubscriptionHandler { ) -> Result { let max_prepay_days = settings.max_prepay_days; Ok(Self { - revolut: settings.get_revolut()?, + revolut, pe: PricingEngine::new(db.clone(), rates, vat), vm_provisioner: VmProvisioner::new(settings, db.clone()), db, @@ -1365,8 +1367,9 @@ mod revolut_autorenew_tests { let payment_method_id = std::env::var("REVOLUT_TEST_PM") .expect("set REVOLUT_TEST_PM to a saved sandbox payment method id"); - let mut settings = mock_settings(); - settings.revolut = Some(revolut); + let settings = mock_settings(); + let revolut_service: Arc = + Arc::new(payments_rs::fiat::RevolutApi::new(revolut)?); let db = Arc::new(MockDb::default()); let node = Arc::new(MockNode::default()); @@ -1427,6 +1430,7 @@ mod revolut_autorenew_tests { db.clone(), node.clone(), Arc::new(MockOnChainProvider::default()), + Some(revolut_service), Arc::new(MockExchangeRate::default()), VatClient::new(), Arc::new(ChannelWorkCommander::new()), @@ -1601,6 +1605,7 @@ mod revolut_offline_tests { db.clone(), node, Arc::new(MockOnChainProvider::default()), + None, rates, VatClient::new(), Arc::new(ChannelWorkCommander::new()), @@ -1671,6 +1676,7 @@ mod revolut_offline_tests { db.clone(), node, Arc::new(MockOnChainProvider::default()), + None, Arc::new(MockExchangeRate::default()), VatClient::new(), Arc::new(ChannelWorkCommander::new()), @@ -1747,6 +1753,7 @@ mod revolut_offline_tests { db.clone(), node, onchain.clone(), + None, Arc::new(MockExchangeRate::default()), VatClient::new(), Arc::new(ChannelWorkCommander::new()), @@ -2095,6 +2102,7 @@ mod sunset_tests { db.clone(), node, Arc::new(MockOnChainProvider::default()), + None, rates, VatClient::new(), Arc::new(ChannelWorkCommander::new()), diff --git a/lnvps_api/src/worker.rs b/lnvps_api/src/worker.rs index 5f0f343..970a76b 100644 --- a/lnvps_api/src/worker.rs +++ b/lnvps_api/src/worker.rs @@ -3346,6 +3346,7 @@ mod tests { db.clone(), node.clone(), Arc::new(MockOnChainProvider::default()), + None, rates, lnvps_api_common::VatClient::new(), work_commander.clone(), From 4285cd4e021eaff9c3aceb2009031ec69fc44c36 Mon Sep 17 00:00:00 2001 From: Kieran Date: Tue, 21 Jul 2026 13:55:45 +0100 Subject: [PATCH 2/3] test(e2e): seed payment_method_config now that YAML config is gone The user API now sources payment providers exclusively from the payment_method_config table and fails to start without an enabled Lightning + on-chain config. Update the E2E harness accordingly: - Start the admin API first (it runs the schema migrations and needs no payment providers), then seed LND Lightning + OnChain payment_method_config rows pointing at the docker-compose LND node, then start the user API. - Drop the now-ignored `lightning:` section from .github/e2e/api-config.yaml. --- .github/e2e/api-config.yaml | 7 ++-- scripts/run-e2e.sh | 72 ++++++++++++++++++++++++++----------- 2 files changed, 53 insertions(+), 26 deletions(-) diff --git a/.github/e2e/api-config.yaml b/.github/e2e/api-config.yaml index 58d67ba..d3a9077 100644 --- a/.github/e2e/api-config.yaml +++ b/.github/e2e/api-config.yaml @@ -1,9 +1,6 @@ db: "mysql://root:root@localhost:3377/lnvps" -lightning: - lnd: - url: "https://localhost:10009" - cert: "/tmp/e2e-lnd/tls.cert" - macaroon: "/tmp/e2e-lnd/data/chain/bitcoin/regtest/admin.macaroon" +# Payment providers (LND Lightning + on-chain) are seeded into the +# payment_method_config table by scripts/run-e2e.sh, not configured here. delete-after: 3 public-url: "http://localhost:8000" read-only: true diff --git a/scripts/run-e2e.sh b/scripts/run-e2e.sh index d2d06b2..1db00ff 100755 --- a/scripts/run-e2e.sh +++ b/scripts/run-e2e.sh @@ -213,51 +213,81 @@ if [[ "$SKIP_BUILD" -eq 0 ]]; then fi # --------------------------------------------------------------------------- -# 6. Start user API +# 6. Start admin API +# +# The admin API runs the database schema migrations on startup (and, unlike the +# user API, does not build any payment providers). We start it first so the +# schema exists before we seed the payment_method_config rows the user API +# needs. # --------------------------------------------------------------------------- -echo "=== Starting user API ===" -LNVPS_NO_DEV_SETUP=1 cargo run -p lnvps_api -- --config "$TMP_API_CONFIG" \ - > /tmp/lnvps-e2e-api.log 2>&1 & -echo $! > "$API_PID_FILE" +echo "=== Starting admin API ===" +LNVPS_NO_DEV_SETUP=1 cargo run -p lnvps_api_admin --bin lnvps_api_admin -- --config "$TMP_ADMIN_CONFIG" \ + > /tmp/lnvps-e2e-admin-api.log 2>&1 & +echo $! > "$ADMIN_PID_FILE" for i in $(seq 1 90); do - if curl -sf "${LNVPS_API_URL:-http://localhost:8000}/" >/dev/null 2>&1; then - echo "User API ready after ${i}s" + if curl -sf "${LNVPS_ADMIN_API_URL:-http://localhost:8001}/" >/dev/null 2>&1; then + echo "Admin API ready after ${i}s" break fi if [[ "$i" -eq 90 ]]; then - echo "ERROR: User API failed to start within 90s" >&2 - echo "--- User API log ---" >&2 - tail -30 /tmp/lnvps-e2e-api.log >&2 + echo "ERROR: Admin API failed to start within 90s" >&2 + echo "--- Admin API log ---" >&2 + tail -30 /tmp/lnvps-e2e-admin-api.log >&2 exit 1 fi sleep 1 done # --------------------------------------------------------------------------- -# 7. Start admin API +# 7. Seed payment providers into the database +# +# Payment providers are now sourced exclusively from the `payment_method_config` +# table (there is no YAML fallback). The user API refuses to start without an +# enabled Lightning + on-chain config for the default company, so seed both to +# point at the docker-compose LND node. Idempotent (skips if already present). # --------------------------------------------------------------------------- -echo "=== Starting admin API ===" -LNVPS_NO_DEV_SETUP=1 cargo run -p lnvps_api_admin --bin lnvps_api_admin -- --config "$TMP_ADMIN_CONFIG" \ - > /tmp/lnvps-e2e-admin-api.log 2>&1 & -echo $! > "$ADMIN_PID_FILE" +echo "=== Seeding payment_method_config (LND Lightning + OnChain) ===" +LND_URL="https://localhost:10009" +LND_CERT="/tmp/e2e-lnd/tls.cert" +LND_MACAROON="/tmp/e2e-lnd/data/chain/bitcoin/regtest/admin.macaroon" +SEED_SQL="USE \`${DB_NAME}\`; +SET @cid = (SELECT MIN(id) FROM company); +INSERT INTO payment_method_config (company_id, payment_method, name, enabled, provider_type, config) +SELECT @cid, 0, 'E2E LND', 1, 'lnd', '{\"type\":\"lnd\",\"url\":\"${LND_URL}\",\"cert_path\":\"${LND_CERT}\",\"macaroon_path\":\"${LND_MACAROON}\"}' +WHERE NOT EXISTS (SELECT 1 FROM payment_method_config WHERE company_id = @cid AND payment_method = 0); +INSERT INTO payment_method_config (company_id, payment_method, name, enabled, provider_type, config) +SELECT @cid, 4, 'E2E LND OnChain', 1, 'onchain', '{\"type\":\"onchain\",\"url\":\"${LND_URL}\",\"cert_path\":\"${LND_CERT}\",\"macaroon_path\":\"${LND_MACAROON}\",\"address_type\":\"witness_pubkey_hash\",\"min_confirmations\":1}' +WHERE NOT EXISTS (SELECT 1 FROM payment_method_config WHERE company_id = @cid AND payment_method = 4);" +if ! mysql_exec "$SEED_SQL"; then + echo "ERROR: failed to seed payment_method_config" >&2 + exit 1 +fi + +# --------------------------------------------------------------------------- +# 8. Start user API +# --------------------------------------------------------------------------- +echo "=== Starting user API ===" +LNVPS_NO_DEV_SETUP=1 cargo run -p lnvps_api -- --config "$TMP_API_CONFIG" \ + > /tmp/lnvps-e2e-api.log 2>&1 & +echo $! > "$API_PID_FILE" for i in $(seq 1 90); do - if curl -sf "${LNVPS_ADMIN_API_URL:-http://localhost:8001}/" >/dev/null 2>&1; then - echo "Admin API ready after ${i}s" + if curl -sf "${LNVPS_API_URL:-http://localhost:8000}/" >/dev/null 2>&1; then + echo "User API ready after ${i}s" break fi if [[ "$i" -eq 90 ]]; then - echo "ERROR: Admin API failed to start within 90s" >&2 - echo "--- Admin API log ---" >&2 - tail -30 /tmp/lnvps-e2e-admin-api.log >&2 + echo "ERROR: User API failed to start within 90s" >&2 + echo "--- User API log ---" >&2 + tail -30 /tmp/lnvps-e2e-api.log >&2 exit 1 fi sleep 1 done # --------------------------------------------------------------------------- -# 8. Run E2E tests +# 9. Run E2E tests # --------------------------------------------------------------------------- echo "=== Running E2E tests ===" TEST_CMD="cargo test -p lnvps_e2e -- --test-threads=1" From f9a66c988c527693ea3db518cbc55f48921188e0 Mon Sep 17 00:00:00 2001 From: Kieran Date: Tue, 21 Jul 2026 14:03:51 +0100 Subject: [PATCH 3/3] fix(admin): run database migrations on admin API startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin API connected to the database but never ran migrations (despite the "Connect database and migrate" comment) — it relied on the user API having migrated first. This lets the admin API bootstrap the schema on its own, which the E2E harness now depends on: it starts the admin API first to create the schema, seeds payment_method_config, then starts the user API. --- lnvps_api_admin/src/bin/admin_api.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lnvps_api_admin/src/bin/admin_api.rs b/lnvps_api_admin/src/bin/admin_api.rs index b5ed19e..bf52878 100644 --- a/lnvps_api_admin/src/bin/admin_api.rs +++ b/lnvps_api_admin/src/bin/admin_api.rs @@ -8,7 +8,7 @@ use lnvps_api_common::{ RedisWorkCommander, RedisWorkFeedback, VmStateCache, WorkCommander, WorkJob, WorkJobMessage, make_exchange_service, }; -use lnvps_db::{EncryptionContext, LNVpsDb, LNVpsDbMysql}; +use lnvps_db::{EncryptionContext, LNVpsDb, LNVpsDbBase, LNVpsDbMysql}; use log::info; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; @@ -68,6 +68,7 @@ async fn main() -> Result<(), Error> { // Connect database and migrate let db = LNVpsDbMysql::new(&settings.db).await?; + db.migrate().await?; let db: Arc = Arc::new(db); // Initialize VM state cache