diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index 2c99bd9c..fae154f1 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to the LNVPS APIs are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [Unreleased] + +### Added + +- **2026-07-14** - Unified saved payment methods + Revolut auto-renewal + - Subscriptions with `auto_renewal_enabled` are now renewed automatically by charging the user's **default** saved payment method, dispatched by provider: Nostr Wallet Connect (Lightning) or a saved Revolut card charged off-session (merchant-initiated). Closes #159. + - Saved methods live in a new provider-agnostic `user_payment_method` table (one-to-many per user, with `is_default` and `enabled`). NWC and Revolut are both modelled as payment methods, so users can keep several and choose which is the default. Only opaque provider token references are stored, encrypted at rest, alongside non-sensitive card metadata (brand, last 4, expiry) for display + expiry handling — never card PAN/CVV. + - Revolut cards are saved automatically the next time the user completes a Revolut checkout while auto-renewal is enabled (no separate setup step). + - **New endpoints:** + - `GET /api/v1/payment-methods` — list saved methods (`id`, `provider`, `name`, `card_brand`, `card_last_four`, `exp_month`, `exp_year`, `is_default`, `enabled`, `created`). Tokens/NWC strings are never returned. + - `POST /api/v1/payment-methods` — add a Nostr Wallet Connect connection (`{ nwc_connection_string, name? }`); validated for `pay_invoice` support. + - `PATCH /api/v1/payment-methods/{id}` — set a user-defined `name`, set as default (`is_default`), and/or enable-disable (`enabled`). + - `DELETE /api/v1/payment-methods/{id}` — remove a saved method. + - **Breaking:** the `nwc_connection_string` field on `GET`/`PATCH /api/v1/account` has been removed. Existing NWC connections are migrated into `user_payment_method` (provider `nwc`); manage NWC via the new payment-methods endpoints instead. + ## [0.4.0] - 2026-07-13 ### Added diff --git a/Cargo.lock b/Cargo.lock index 0e40d3b5..32bb0727 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3513,7 +3513,7 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "payments-rs" version = "0.2.0" -source = "git+https://github.com/v0l/payments-rs.git?rev=4e7d5fb80902973a7eb241ba6bad178012b6f169#4e7d5fb80902973a7eb241ba6bad178012b6f169" +source = "git+https://github.com/v0l/payments-rs.git?rev=f4456beb68d800a2a0b386b74719a59087cb74cb#f4456beb68d800a2a0b386b74719a59087cb74cb" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 973c238e..60b18243 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ tower-http = { version = "0.6", features = ["cors"] } config = { version = "0.15", features = ["yaml"] } hex = "0.4" ipnetwork = "0.21" -payments-rs = { git = "https://github.com/v0l/payments-rs.git", rev = "4e7d5fb80902973a7eb241ba6bad178012b6f169", default-features = false } +payments-rs = { git = "https://github.com/v0l/payments-rs.git", rev = "f4456beb68d800a2a0b386b74719a59087cb74cb", default-features = false } async-trait = "0.1" futures = "0.3" reqwest = { version = "0.13", features = ["json"] } diff --git a/lnvps_api/examples/revolut_sandbox.rs b/lnvps_api/examples/revolut_sandbox.rs new file mode 100644 index 00000000..f0b31e46 --- /dev/null +++ b/lnvps_api/examples/revolut_sandbox.rs @@ -0,0 +1,176 @@ +//! Interactive Revolut sandbox harness for the saved-payment-method / +//! off-session auto-renewal flow (issue #159). +//! +//! This exercises the real `payments-rs` Revolut integration against the +//! Revolut **sandbox**. It is NOT a unit test — it talks to the live sandbox +//! API and (for `create`) produces a hosted checkout URL that must be paid once +//! with a test card before an off-session charge can be made. +//! +//! Credentials are read from a YAML file (default `config.local.yaml`, which is +//! gitignored) with a top-level `revolut:` section: +//! +//! ```yaml +//! revolut: +//! url: "https://sandbox-merchant.revolut.com" +//! api-version: "2024-09-01" +//! token: "sk_sandbox_..." +//! public-key: "pk_sandbox_..." +//! ``` +//! +//! Flow: +//! 1. cargo run --example revolut_sandbox --features revolut -- create 9.99 EUR +//! -> prints ORDER_ID and CHECKOUT_URL +//! (pay the CHECKOUT_URL once with a Revolut test card, e.g. 4111 1111 1111 1111) +//! 2. cargo run --example revolut_sandbox --features revolut -- status +//! -> prints state + CUSTOMER_ID + PAYMENT_METHOD_ID once the checkout completes +//! 3. cargo run --example revolut_sandbox --features revolut -- charge 9.99 EUR +//! -> off-session (merchant-initiated) charge; prints final order state + +use anyhow::{Context, Result, bail}; +use clap::{Parser, Subcommand}; +use config::{Config, File}; +use payments_rs::currency::{Currency, CurrencyAmount}; +use payments_rs::fiat::{FiatPaymentService, RevolutApi, RevolutConfig}; +use serde::Deserialize; +use std::path::PathBuf; +use std::str::FromStr; + +#[derive(Parser)] +#[command(about = "Revolut sandbox saved-card / off-session test harness")] +struct Args { + /// Path to a YAML config file containing a `revolut:` section + #[arg(long, default_value = "config.local.yaml")] + config: PathBuf, + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Create a savable subscription checkout (returns a checkout URL to pay once) + Create { + /// Amount in major units (e.g. 9.99) + amount: f32, + /// Currency code (e.g. EUR) + currency: String, + /// Customer email (a customer must be attached to save the payment method) + #[arg(default_value = "test@lnvps.net")] + email: String, + }, + /// Fetch an order's current state + any saved customer/payment-method ids + Status { + /// Revolut order id + order_id: String, + }, + /// Off-session (merchant-initiated) charge against a saved payment method + Charge { + customer_id: String, + payment_method_id: String, + amount: f32, + currency: String, + }, +} + +#[derive(Deserialize)] +#[serde(rename_all = "kebab-case")] +struct HarnessConfig { + revolut: RevolutConfig, +} + +fn load_api(path: &PathBuf) -> Result { + let cfg: HarnessConfig = Config::builder() + .add_source(File::from(path.clone())) + .build() + .with_context(|| format!("failed to load config from {}", path.display()))? + .try_deserialize() + .context("config missing a valid `revolut:` section")?; + RevolutApi::new(cfg.revolut).context("failed to build RevolutApi") +} + +fn parse_amount(amount: f32, currency: &str) -> Result { + let c = Currency::from_str(currency).map_err(|_| anyhow::anyhow!("bad currency"))?; + if c == Currency::BTC { + bail!("BTC is not a fiat currency"); + } + Ok(CurrencyAmount::from_f32(c, amount)) +} + +#[tokio::main] +async fn main() -> Result<()> { + env_logger::init(); + let args = Args::parse(); + let api = load_api(&args.config)?; + + match args.cmd { + Cmd::Create { + amount, + currency, + email, + } => { + let amt = parse_amount(amount, ¤cy)?; + // Exercises the real code path: create_subscription attaches a + // customer (via email) + save_payment_method_for=merchant so the + // card is saved for future off-session charges. + let info = api + .create_subscription( + "LNVPS auto-renewal sandbox test", + amt, + Some(email), + None, + ) + .await?; + println!("ORDER_ID={}", info.external_id); + println!( + "CUSTOMER_ID={}", + info.customer_id.clone().unwrap_or_else(|| "".into()) + ); + let checkout_url = info.checkout_url.clone().unwrap_or_default(); + // The widget needs the order's public token (last path segment of the checkout url) + let token = checkout_url.rsplit('/').next().unwrap_or("").to_string(); + println!("TOKEN={}", token); + println!("CHECKOUT_URL={}", checkout_url); + println!("--> Save a card via the widget (savePaymentMethodFor=merchant), then run `status`."); + } + Cmd::Status { order_id } => { + let order = api.get_order(&order_id).await?; + println!("STATE={:?}", order.state); + let customer_id = order.customer_id(); + println!( + "CUSTOMER_ID={}", + customer_id.clone().unwrap_or_else(|| "".into()) + ); + // The reusable saved payment method is fetched from the customer + // endpoint (not the order). + if let Some(cust) = customer_id { + let methods = api.get_customer_payment_methods(&cust, true).await?; + if methods.is_empty() { + println!("PAYMENT_METHOD_ID= (no merchant-initiated method saved yet)"); + } + for m in methods { + println!("PAYMENT_METHOD_ID={} TYPE={:?} SAVED_FOR={:?}", m.id, m.kind, m.saved_for); + } + } + } + Cmd::Charge { + customer_id, + payment_method_id, + amount, + currency, + } => { + let amt = parse_amount(amount, ¤cy)?; + let order = api + .create_off_session_order( + &customer_id, + &payment_method_id, + payments_rs::fiat::RevolutSavedPaymentMethodType::Card, + amt, + Some("LNVPS off-session auto-renewal sandbox test".to_string()), + ) + .await?; + println!("ORDER_ID={}", order.id); + println!("STATE={:?}", order.state); + println!("OUTSTANDING={}", order.outstanding_amount); + } + } + Ok(()) +} diff --git a/lnvps_api/examples/revolut_widget.html b/lnvps_api/examples/revolut_widget.html new file mode 100644 index 00000000..bab8faf9 --- /dev/null +++ b/lnvps_api/examples/revolut_widget.html @@ -0,0 +1,41 @@ + + + + + Revolut Save-Card Widget Harness + + + +

loading

+
+ + + + diff --git a/lnvps_api/src/api/model.rs b/lnvps_api/src/api/model.rs index c54bfcdf..faef666d 100644 --- a/lnvps_api/src/api/model.rs +++ b/lnvps_api/src/api/model.rs @@ -137,13 +137,63 @@ pub struct AccountPatchRequest { deserialize_with = "lnvps_api_common::deserialize_nullable_option" )] pub tax_id: Option>, - /// Nostr Wallet Connect connection string for automatic VM renewals +} + +/// A saved payment method for automatic renewals. Never exposes the underlying +/// provider tokens / NWC connection string. +#[derive(Serialize, Deserialize)] +pub struct PaymentMethodResponse { + pub id: u64, + /// Payment processor: `nwc` or `revolut` + pub provider: String, + /// Optional user-defined label + pub name: Option, + pub created: DateTime, + pub card_brand: Option, + pub card_last_four: Option, + pub exp_month: Option, + pub exp_year: Option, + pub is_default: bool, + pub enabled: bool, +} + +impl From for PaymentMethodResponse { + fn from(m: lnvps_db::UserPaymentMethod) -> Self { + PaymentMethodResponse { + id: m.id, + provider: m.provider, + name: m.name, + created: m.created, + card_brand: m.card_brand, + card_last_four: m.card_last_four, + exp_month: m.exp_month, + exp_year: m.exp_year, + is_default: m.is_default, + enabled: m.enabled, + } + } +} + +/// Add a Nostr Wallet Connect connection as a saved payment method. +#[derive(Deserialize)] +pub struct AddNwcPaymentMethodRequest { + pub nwc_connection_string: String, + /// Optional user-defined label + pub name: Option, +} + +/// Update a saved payment method (label / set default / enable-disable). +#[derive(Deserialize)] +pub struct PatchPaymentMethodRequest { + pub is_default: Option, + pub enabled: Option, + /// Set/clear the user-defined label. Use `Some(Some(..))` to set, `Some(None)` to clear. #[serde( default, skip_serializing_if = "Option::is_none", deserialize_with = "lnvps_api_common::deserialize_nullable_option" )] - pub nwc_connection_string: Option>, + pub name: Option>, } impl From for AccountPatchRequest { @@ -172,7 +222,6 @@ impl From for AccountPatchRequest { city: Some(user.billing_city), postcode: Some(user.billing_postcode), tax_id: Some(user.billing_tax_id), - nwc_connection_string: Some(user.nwc_connection_string.map(|nwc| nwc.into())), } } } diff --git a/lnvps_api/src/api/referral.rs b/lnvps_api/src/api/referral.rs index 7f118654..4ad3066f 100644 --- a/lnvps_api/src/api/referral.rs +++ b/lnvps_api/src/api/referral.rs @@ -167,6 +167,15 @@ async fn validate_lightning_address(addr: &str) -> Result<(), ApiError> { } /// Generate a random 8-character base63 referral code (A-Za-z0-9_) +/// Whether the user has an enabled NWC payment method configured. +async fn user_has_nwc(this: &RouterState, uid: u64) -> bool { + this.db + .list_user_payment_methods(uid, Some("nwc")) + .await + .map(|m| m.iter().any(|pm| pm.enabled)) + .unwrap_or(false) +} + fn generate_referral_code() -> String { const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_"; let bytes: [u8; 8] = rand::random(); @@ -230,12 +239,9 @@ async fn v1_signup_referral( validate_lightning_address(addr).await?; } - // If use_nwc is requested, ensure user has NWC configured - if req.use_nwc { - let user = this.db.get_user(uid).await?; - if user.nwc_connection_string.is_none() { - return ApiData::err("NWC connection is not configured on your account"); - } + // If use_nwc is requested, ensure user has an NWC payment method configured + if req.use_nwc && !user_has_nwc(&this, uid).await { + return ApiData::err("NWC connection is not configured on your account"); } let code = generate_referral_code(); @@ -276,11 +282,8 @@ async fn v1_update_referral( referral.lightning_address = addr.clone(); } if let Some(use_nwc) = req.use_nwc { - if use_nwc { - let user = this.db.get_user(uid).await?; - if user.nwc_connection_string.is_none() { - return ApiData::err("NWC connection is not configured on your account"); - } + if use_nwc && !user_has_nwc(&this, uid).await { + return ApiData::err("NWC connection is not configured on your account"); } referral.use_nwc = use_nwc; } diff --git a/lnvps_api/src/api/routes.rs b/lnvps_api/src/api/routes.rs index 76ae7e95..49aa27f1 100644 --- a/lnvps_api/src/api/routes.rs +++ b/lnvps_api/src/api/routes.rs @@ -31,9 +31,10 @@ use crate::api::model::{ AccountPatchRequest, ApiCompany, ApiCustomTemplateParams, ApiCustomVmOrder, ApiCustomVmRequest, ApiInvoiceItem, ApiPaymentInfo, ApiPaymentMethod, ApiTemplatesResponse, ApiVmFirewallPolicy, ApiVmFirewallRule, ApiVmHistory, ApiVmPayment, ApiVmStatus, ApiVmUpgradeQuote, - ApiVmUpgradeRequest, CreateSshKey, CreateVmFirewallRule, CreateVmRequest, - PatchVmFirewallPolicy, PatchVmFirewallRule, VMPatchRequest, validate_firewall_cidr, - validate_firewall_ports, vm_to_status, + ApiVmUpgradeRequest, AddNwcPaymentMethodRequest, CreateSshKey, CreateVmFirewallRule, + CreateVmRequest, PatchPaymentMethodRequest, PatchVmFirewallPolicy, PatchVmFirewallRule, + PaymentMethodResponse, VMPatchRequest, validate_firewall_cidr, validate_firewall_ports, + vm_to_status, }; use crate::api::{AmountQuery, AuthQuery, PaymentMethodQuery, RouterState}; use crate::host::{FullVmInfo, TimeSeries, TimeSeriesData, get_host_client}; @@ -46,6 +47,14 @@ pub fn routes() -> Router { get(v1_get_account).patch(v1_patch_account), ) .route("/api/v1/account/verify-email", get(v1_verify_email)) + .route( + "/api/v1/payment-methods", + get(v1_list_payment_methods).post(v1_add_nwc_payment_method), + ) + .route( + "/api/v1/payment-methods/{id}", + patch(v1_patch_payment_method).delete(v1_delete_payment_method), + ) .route( "/api/v1/account/telegram/link", post(v1_telegram_link).delete(v1_telegram_unlink), @@ -133,27 +142,6 @@ async fn v1_patch_account( let uid = this.db.upsert_user(&pubkey).await?; let mut user = this.db.get_user(uid).await?; - // validate nwc string (skip validation if empty - treat as clearing the value) - #[cfg(feature = "nostr-nwc")] - if let Some(Some(nwc)) = &req.nwc_connection_string - && !nwc.is_empty() - { - match nwc::prelude::NostrWalletConnectUri::parse(nwc) { - Ok(s) => { - // test connection - let client = nwc::NostrWalletConnect::new(s); - let info = client - .get_info() - .await - .map_err(|e| ApiError::bad_request(format!("Failed to connect to NWC: {}", e)))?; - if !info.methods.contains(&nwc::prelude::Method::PayInvoice) { - return ApiData::err("NWC connection must allow pay_invoice"); - } - } - Err(e) => return ApiData::err(&format!("Failed to parse NWC url: {}", e)), - } - } - // validate tax_id if provided if let Some(Some(tax_id)) = &req.tax_id { let vat_client = EuVatClient::new(); @@ -245,13 +233,6 @@ async fn v1_patch_account( if let Some(tax_id) = &req.tax_id { user.billing_tax_id = tax_id.clone(); } - if let Some(nwc_connection_string) = &req.nwc_connection_string { - // Treat empty string as None (clear the value) - user.nwc_connection_string = nwc_connection_string - .clone() - .filter(|s| !s.is_empty()) - .map(|s| s.into()); - } this.db.update_user(&user).await?; @@ -349,10 +330,128 @@ async fn v1_get_account( let pubkey = auth.event.pubkey.to_bytes(); let uid = this.db.upsert_user(&pubkey).await?; let user = this.db.get_user(uid).await?; - ApiData::ok(user.into()) } +/// List the user's saved payment methods for automatic renewals. +async fn v1_list_payment_methods( + auth: Nip98Auth, + State(this): State, +) -> ApiResult> { + let pubkey = auth.event.pubkey.to_bytes(); + let uid = this.db.upsert_user(&pubkey).await?; + let methods = this.db.list_user_payment_methods(uid, None).await?; + ApiData::ok(methods.into_iter().map(Into::into).collect()) +} + +/// Add a Nostr Wallet Connect connection as a saved payment method. +async fn v1_add_nwc_payment_method( + auth: Nip98Auth, + State(this): State, + req: Json, +) -> ApiResult { + let pubkey = auth.event.pubkey.to_bytes(); + let uid = this.db.upsert_user(&pubkey).await?; + + let nwc = req.nwc_connection_string.trim().to_string(); + if nwc.is_empty() { + return ApiData::err("NWC connection string cannot be empty"); + } + + // Validate the NWC connection and ensure it can pay invoices. + #[cfg(feature = "nostr-nwc")] + match nwc::prelude::NostrWalletConnectUri::parse(&nwc) { + Ok(s) => { + let client = nwc::NostrWalletConnect::new(s); + let info = client + .get_info() + .await + .map_err(|e| ApiError::bad_request(format!("Failed to connect to NWC: {}", e)))?; + if !info.methods.contains(&nwc::prelude::Method::PayInvoice) { + return ApiData::err("NWC connection must allow pay_invoice"); + } + } + Err(e) => return ApiData::err(&format!("Failed to parse NWC url: {}", e)), + } + + // First method for the user becomes the default. + let existing = this.db.list_user_payment_methods(uid, None).await?; + let pm = lnvps_db::UserPaymentMethod { + id: 0, + user_id: uid, + created: chrono::Utc::now(), + provider: "nwc".to_string(), + name: req.name.as_ref().map(|n| n.trim().to_string()).filter(|n| !n.is_empty()), + external_customer_id: None, + external_id: nwc.into(), + card_brand: None, + card_last_four: None, + exp_month: None, + exp_year: None, + is_default: existing.is_empty(), + enabled: true, + }; + let id = this.db.insert_user_payment_method(&pm).await?; + let saved = this.db.get_user_payment_method(id).await?; + ApiData::ok(saved.into()) +} + +/// Update a saved payment method: set it as default and/or enable-disable it. +async fn v1_patch_payment_method( + auth: Nip98Auth, + State(this): State, + Path(id): Path, + req: Json, +) -> ApiResult { + let pubkey = auth.event.pubkey.to_bytes(); + let uid = this.db.upsert_user(&pubkey).await?; + + let mut method = this.db.get_user_payment_method(id).await?; + if method.user_id != uid { + return ApiData::err("Payment method not found"); + } + + if let Some(enabled) = req.enabled { + method.enabled = enabled; + } + if let Some(name) = &req.name { + method.name = name + .clone() + .map(|n| n.trim().to_string()) + .filter(|n| !n.is_empty()); + } + if req.is_default == Some(true) { + // Only one default: clear the flag on the user's other methods. + for mut other in this.db.list_user_payment_methods(uid, None).await? { + if other.id != id && other.is_default { + other.is_default = false; + this.db.update_user_payment_method(&other).await?; + } + } + method.is_default = true; + } else if req.is_default == Some(false) { + method.is_default = false; + } + this.db.update_user_payment_method(&method).await?; + ApiData::ok(this.db.get_user_payment_method(id).await?.into()) +} + +/// Delete a saved payment method. +async fn v1_delete_payment_method( + auth: Nip98Auth, + State(this): State, + Path(id): Path, +) -> ApiResult<()> { + let pubkey = auth.event.pubkey.to_bytes(); + let uid = this.db.upsert_user(&pubkey).await?; + let method = this.db.get_user_payment_method(id).await?; + if method.user_id != uid { + return ApiData::err("Payment method not found"); + } + this.db.delete_user_payment_method(id).await?; + ApiData::ok(()) +} + /// Notification channels configured on this server. The UI can use this to /// show/hide the relevant contact inputs since there's no point offering a /// channel that isn't configured on the backend. @@ -857,20 +956,22 @@ async fn v1_renew_vm( Query(q): Query, ) -> ApiResult { let (uid, vm) = get_user_vm(&auth, &this, id).await?; - let user = this.db.get_user(uid).await?; let intervals = q.intervals.unwrap_or(1); let vm_line = this .db .get_subscription_line_item(vm.subscription_line_item_id) .await?; - // handle "nwc" payments automatically - let payment = if q.method.as_deref() == Some("nwc") && user.nwc_connection_string.is_some() { + // handle "nwc" payments automatically (uses the user's saved NWC method) + let has_nwc = this + .db + .list_user_payment_methods(uid, Some("nwc")) + .await + .map(|m| m.iter().any(|pm| pm.enabled)) + .unwrap_or(false); + let payment = if q.method.as_deref() == Some("nwc") && has_nwc { this.sub_handler - .auto_renew_via_nwc( - vm_line.subscription_id, - user.nwc_connection_string.unwrap().as_str(), - ) + .auto_renew_via_nwc(vm_line.subscription_id) .await? } else { this.sub_handler diff --git a/lnvps_api/src/payments/revolut.rs b/lnvps_api/src/payments/revolut.rs index 0222b1a3..641d0bed 100644 --- a/lnvps_api/src/payments/revolut.rs +++ b/lnvps_api/src/payments/revolut.rs @@ -1,8 +1,10 @@ use crate::subscription::SubscriptionHandler; use anyhow::{Context, Result}; - +use chrono::Utc; use isocountry::CountryCode; -use lnvps_db::{LNVpsDb, PaymentMethodConfig, ProviderConfig, SubscriptionPaymentType}; +use lnvps_db::{ + LNVpsDb, PaymentMethodConfig, ProviderConfig, SubscriptionPaymentType, UserPaymentMethod, +}; use log::{error, info, warn}; use payments_rs::fiat::{ RevolutApi, RevolutConfig, RevolutOrderState, RevolutWebhookBody, RevolutWebhookEvent, @@ -167,6 +169,58 @@ impl RevolutPaymentHandler { Ok(secret) } + /// Fetch the customer's merchant-initiated (off-session capable) saved + /// payment method and persist it as a `UserPaymentMethod` for automatic + /// renewals. Idempotent: skips methods already stored for the user. + async fn capture_saved_payment_method(&self, user_id: u64, customer_id: &str) -> Result<()> { + let methods = self + .api + .get_customer_payment_methods(customer_id, true) + .await?; + let Some(method) = methods.into_iter().next() else { + return Ok(()); + }; + + // Dedupe: compare against already-stored Revolut methods (external_id is + // encrypted with a non-deterministic cipher, so compare decrypted). + let existing = self + .db + .list_user_payment_methods(user_id, Some("revolut")) + .await + .unwrap_or_default(); + let already_stored = existing.iter().any(|m| { + let stored: String = m.external_id.clone().into(); + stored == method.id + }); + if already_stored { + return Ok(()); + } + + let card = method.method_details.as_ref(); + let pm = UserPaymentMethod { + id: 0, + user_id, + created: Utc::now(), + provider: "revolut".to_string(), + name: None, + external_customer_id: Some(customer_id.to_string().into()), + external_id: method.id.clone().into(), + card_brand: card.and_then(|c| c.brand.clone()), + card_last_four: card.and_then(|c| c.last4.clone()), + exp_month: card.and_then(|c| c.expiry_month), + exp_year: card.and_then(|c| c.expiry_year), + // First saved method for the user becomes the default. + is_default: existing.is_empty(), + enabled: true, + }; + self.db.insert_user_payment_method(&pm).await?; + info!( + "Saved Revolut payment method for user {} (off-session automatic renewals)", + user_id + ); + Ok(()) + } + async fn try_complete_payment(&self, ext_id: &str) -> Result<()> { let mut payment = self.db.get_subscription_payment_by_ext_id(ext_id).await?; @@ -178,12 +232,13 @@ impl RevolutPaymentHandler { } payment.external_data = serde_json::to_string(&order)?.into(); - // Update user country from card country if not already set (best-effort) + // Update user country from the card country if not set (best-effort). if let Some(cc) = order .payments - .and_then(|p| p.first().cloned()) - .and_then(|p| p.payment_method) - .and_then(|p| p.card_country_code) + .as_ref() + .and_then(|p| p.first()) + .and_then(|p| p.payment_method.as_ref()) + .and_then(|p| p.card_country_code.clone()) .and_then(|c| CountryCode::for_alpha2(&c).ok()) { if let Ok(mut user) = self.db.get_user(payment.user_id).await { @@ -194,6 +249,20 @@ impl RevolutPaymentHandler { } } + // Capture any saved payment method for future off-session + // (merchant-initiated) automatic renewals. We store only opaque Revolut + // token references plus non-sensitive card metadata, never card data. + // The reusable payment method is NOT on the order — fetch it from the + // customer's saved payment methods (filtered to merchant capability). + if let Some(customer_id) = order.customer_id() { + if let Err(e) = self.capture_saved_payment_method(payment.user_id, &customer_id).await { + warn!( + "Failed to capture saved Revolut payment method for user {}: {}", + payment.user_id, e + ); + } + } + let result = self.subscription_handler.complete_payment(&payment).await?; for p in result.expired_competing_upgrades { if let Some(eid) = p.external_id.as_ref() { diff --git a/lnvps_api/src/subscription/mod.rs b/lnvps_api/src/subscription/mod.rs index a6d42646..70f6b344 100644 --- a/lnvps_api/src/subscription/mod.rs +++ b/lnvps_api/src/subscription/mod.rs @@ -15,14 +15,14 @@ use anyhow::{Context, Result, bail, ensure}; use async_trait::async_trait; -use chrono::Utc; +use chrono::{Datelike, Utc}; use lnvps_api_common::{ CostResult, ExchangeRateService, NewPaymentInfo, PricingEngine, UpgradeConfig, WorkCommander, round_msat_to_sat, }; use lnvps_db::{ LNVpsDb, PaymentMethod, Subscription, SubscriptionLineItem, SubscriptionPayment, - SubscriptionPaymentType, SubscriptionType, + SubscriptionPaymentType, SubscriptionType, User, UserPaymentMethod, }; use log::{debug, info, warn}; use payments_rs::currency::{Currency, CurrencyAmount}; @@ -122,6 +122,11 @@ impl SubscriptionHandler { self.db.clone() } + #[cfg(test)] + pub(crate) fn set_revolut_for_test(&mut self, r: Arc) { + self.revolut = Some(r); + } + pub async fn make_line_item_handler( &self, li: &SubscriptionLineItem, @@ -241,12 +246,92 @@ impl SubscriptionHandler { } } - /// Create a renewal/purchase payment for a subscription + /// Create a Revolut order for a fiat payment, returning `(external_id, raw_data)`. + /// + /// When the subscription has automatic renewal enabled and the user does not + /// yet have a saved payment method, the order is created as a "subscription" + /// checkout (`create_subscription`) so Revolut saves the customer's payment + /// method for future off-session (merchant-initiated) charges. The saved + /// method is captured on webhook completion (see + /// `payments::revolut::RevolutPaymentHandler::capture_saved_payment_method`). + /// Select the user's saved Revolut payment method to charge off-session: + /// the default enabled, non-expired method, else the first enabled + /// non-expired one. + async fn default_revolut_payment_method(&self, user_id: u64) -> Result { + let now = Utc::now(); + let (year, month) = (now.year() as u16, now.month() as u16); + let methods = self + .db + .list_user_payment_methods(user_id, Some("revolut")) + .await?; + let usable: Vec = methods + .into_iter() + .filter(|m| m.enabled && !m.is_expired(year, month)) + .collect(); + // list_user_payment_methods already orders default-first. + usable + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("No usable saved Revolut payment method")) + } + + async fn create_revolut_order( + &self, + rev: &Arc, + subscription: &Subscription, + user: &User, + payment_type: SubscriptionPaymentType, + desc: &str, + amount: CurrencyAmount, + ) -> Result<(String, String)> { + // Save the card only when auto-renewal is on, this isn't an upgrade, and + // the user has no usable saved Revolut method yet. + let has_saved_method = self + .db + .list_user_payment_methods(user.id, Some("revolut")) + .await + .map(|m| m.iter().any(|pm| pm.enabled)) + .unwrap_or(false); + let should_save = subscription.auto_renewal_enabled + && !has_saved_method + && payment_type != SubscriptionPaymentType::Upgrade; + if should_save { + let email: String = user.email.clone().into(); + let customer_email = if email.is_empty() { None } else { Some(email) }; + let info = rev + .create_subscription(desc, amount, customer_email, None) + .await?; + Ok((info.external_id, info.raw_data)) + } else { + let info = rev.create_order(desc, amount, None).await?; + Ok((info.external_id, info.raw_data)) + } + } + + /// Create a renewal/purchase payment for a subscription. + /// + /// The customer completes the payment interactively (Lightning invoice or + /// Revolut checkout). pub async fn renew_subscription( &self, subscription_id: u64, method: PaymentMethod, intervals: u32, + ) -> Result { + self.renew_subscription_inner(subscription_id, method, intervals, false) + .await + } + + /// Create a renewal/purchase payment for a subscription. + /// + /// When `off_session` is true (Revolut only) the saved payment method is + /// charged off-session (merchant-initiated) without customer interaction. + async fn renew_subscription_inner( + &self, + subscription_id: u64, + method: PaymentMethod, + intervals: u32, + off_session: bool, ) -> Result { let intervals = intervals.max(1); @@ -471,7 +556,37 @@ impl SubscriptionHandler { converted_currency, converted_amount + tax + processing_fee, ); - let order = rev.create_order(&desc, order_amount, None).await?; + let (external_id, raw_data) = if off_session { + // Off-session (merchant-initiated) charge against the user's + // default saved Revolut payment method — no customer + // interaction. + let method = self.default_revolut_payment_method(user.id).await?; + let customer_id: String = method + .external_customer_id + .clone() + .ok_or_else(|| anyhow::anyhow!("Revolut method missing customer id"))? + .into(); + let payment_method_id: String = method.external_id.clone().into(); + let info = rev + .charge_subscription( + &customer_id, + &payment_method_id, + order_amount, + &desc, + ) + .await?; + (info.external_id, info.raw_data) + } else { + self.create_revolut_order( + rev, + &subscription, + &user, + payment_type, + &desc, + order_amount, + ) + .await? + }; let new_id: [u8; 32] = rand::random(); SubscriptionPayment { @@ -484,8 +599,8 @@ impl SubscriptionHandler { currency: converted_currency.to_string(), payment_method: method, payment_type, - external_data: order.raw_data.into(), - external_id: Some(order.external_id), + external_data: raw_data.into(), + external_id: Some(external_id), is_paid: false, rate, time_value: if time_value > 0 { @@ -602,14 +717,19 @@ impl SubscriptionHandler { p.currency != Currency::BTC, "Cannot create revolut orders for BTC currency" ); - let order = rev - .create_order( + let subscription = self.db.get_subscription(subscription_id).await?; + let user = self.db.get_user(vm.user_id).await?; + let (external_id, raw_data) = self + .create_revolut_order( + rev, + &subscription, + &user, + payment_type, &desc, CurrencyAmount::from_u64( p.currency, p.amount + p.tax + p.processing_fee, ), - None, ) .await?; let new_id: [u8; 32] = rand::random(); @@ -623,8 +743,8 @@ impl SubscriptionHandler { currency: p.currency.to_string(), payment_method: method, payment_type, - external_data: order.raw_data.into(), - external_id: Some(order.external_id), + external_data: raw_data.into(), + external_id: Some(external_id), is_paid: false, rate: p.rate.rate, time_value: Some(p.time_value), @@ -647,17 +767,61 @@ impl SubscriptionHandler { } } + /// The user's default usable (enabled, non-expired) saved payment method, + /// across all providers. Methods are ordered default-first. + pub async fn default_payment_method(&self, user_id: u64) -> Result { + let now = Utc::now(); + let (year, month) = (now.year() as u16, now.month() as u16); + self.db + .list_user_payment_methods(user_id, None) + .await? + .into_iter() + .find(|pm| pm.enabled && !pm.is_expired(year, month)) + .ok_or_else(|| anyhow::anyhow!("No usable saved payment method")) + } + + /// The user's first enabled NWC payment method. + async fn nwc_payment_method(&self, user_id: u64) -> Result { + self.db + .list_user_payment_methods(user_id, Some("nwc")) + .await? + .into_iter() + .find(|pm| pm.enabled) + .ok_or_else(|| anyhow::anyhow!("No NWC payment method configured")) + } + + /// Attempt automatic renewal using the user's default saved payment method, + /// dispatching by provider (NWC Lightning wallet or Revolut card). + pub async fn auto_renew(&self, sub_id: u64) -> Result { + let sub = self.db.get_subscription(sub_id).await?; + let method = self.default_payment_method(sub.user_id).await?; + match method.provider.as_str() { + "nwc" => { + #[cfg(feature = "nostr-nwc")] + { + self.auto_renew_via_nwc(sub_id).await + } + #[cfg(not(feature = "nostr-nwc"))] + { + bail!("NWC auto-renewal is not supported by this build") + } + } + "revolut" => self.auto_renew_via_revolut(sub_id).await, + other => bail!("No auto-renewal handler for provider {other}"), + } + } + #[cfg(feature = "nostr-nwc")] - /// Attempt automatic renewal via Nostr Wallet Connect - pub async fn auto_renew_via_nwc( - &self, - sub_id: u64, - nwc_string: &str, - ) -> Result { + /// Attempt automatic renewal via the user's saved Nostr Wallet Connect method + pub async fn auto_renew_via_nwc(&self, sub_id: u64) -> Result { use nostr_sdk::prelude::*; debug!("Attempting automatic renewal for sub {} via NWC", sub_id); + let sub = self.db.get_subscription(sub_id).await?; + let nwc_method = self.nwc_payment_method(sub.user_id).await?; + let nwc_string: String = nwc_method.external_id.clone().into(); + // Use existing renew_subscription method to create the payment/invoice let vm_payment = self .renew_subscription(sub_id, PaymentMethod::Lightning, 1) @@ -671,8 +835,8 @@ impl SubscriptionHandler { ); // Parse NWC connection string - let nwc_uri = - NostrWalletConnectUri::from_str(nwc_string).context("Invalid NWC connection string")?; + let nwc_uri = NostrWalletConnectUri::from_str(&nwc_string) + .context("Invalid NWC connection string")?; // Create nostr client for NWC let client = nwc::NostrWalletConnect::new(nwc_uri); @@ -681,6 +845,27 @@ impl SubscriptionHandler { Ok(vm_payment) } + /// Attempt automatic renewal by charging the user's saved Revolut payment + /// method off-session (merchant-initiated). + /// + /// The charge is submitted immediately; the resulting Revolut order is + /// completed asynchronously via the `OrderCompleted` webhook (same path as + /// interactive Revolut payments), which extends the subscription expiry. + pub async fn auto_renew_via_revolut(&self, sub_id: u64) -> Result { + debug!( + "Attempting automatic renewal for sub {} via Revolut saved card", + sub_id + ); + let payment = self + .renew_subscription_inner(sub_id, PaymentMethod::Revolut, 1, true) + .await?; + info!( + "Submitted Revolut off-session auto-renewal charge for sub {}", + sub_id + ); + Ok(payment) + } + /// Renew a VM using a specific amount pub async fn renew_amount( &self, @@ -726,3 +911,428 @@ impl SubscriptionHandler { .await } } + +#[cfg(all(test, feature = "revolut"))] +mod revolut_autorenew_tests { + use super::*; + use crate::mocks::MockNode; + use crate::settings::mock_settings; + use config::{Config, File}; + use lnvps_api_common::{ChannelWorkCommander, MockDb, MockExchangeRate, VmStateCache}; + use lnvps_db::{ + IntervalType, LNVpsDbBase, Subscription, SubscriptionLineItem, UserPaymentMethod, + }; + use payments_rs::fiat::RevolutConfig; + use serde::Deserialize; + use std::path::PathBuf; + + #[derive(Deserialize)] + #[serde(rename_all = "kebab-case")] + struct HarnessConfig { + revolut: RevolutConfig, + } + + /// Load the sandbox Revolut config from config.local.yaml (repo root). + fn load_revolut() -> Option { + // Test runs from the crate dir (lnvps_api/), config.local.yaml is at the + // workspace root. + for p in ["../config.local.yaml", "config.local.yaml"] { + if PathBuf::from(p).exists() { + let cfg: HarnessConfig = Config::builder() + .add_source(File::from(PathBuf::from(p))) + .build() + .ok()? + .try_deserialize() + .ok()?; + return Some(cfg.revolut); + } + } + None + } + + /// End-to-end auto-renew against the Revolut sandbox. Requires: + /// - config.local.yaml with a `revolut:` sandbox section + /// - a previously-saved sandbox customer + payment method (env below) + /// Run with: + /// REVOLUT_TEST_CUSTOMER= REVOLUT_TEST_PM= \ + /// cargo test -p lnvps_api --features revolut -- --ignored autorenew + #[tokio::test] + #[ignore = "hits the Revolut sandbox; needs config.local.yaml + saved method env"] + async fn auto_renew_via_revolut_charges_saved_card() -> Result<()> { + let Some(revolut) = load_revolut() else { + eprintln!("skipping: no config.local.yaml revolut section"); + return Ok(()); + }; + let customer_id = std::env::var("REVOLUT_TEST_CUSTOMER") + .expect("set REVOLUT_TEST_CUSTOMER to a saved sandbox customer id"); + 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 db = Arc::new(MockDb::default()); + let node = Arc::new(MockNode::default()); + + // Seed user + saved Revolut method + auto-renew subscription (EUR). + let user_id = db.upsert_user(&[7u8; 32]).await?; + db.insert_user_payment_method(&UserPaymentMethod { + id: 0, + user_id, + created: Utc::now(), + provider: "revolut".to_string(), + name: None, + external_customer_id: Some(customer_id.into()), + external_id: payment_method_id.into(), + card_brand: Some("VISA".to_string()), + card_last_four: Some("5709".to_string()), + exp_month: Some(12), + exp_year: Some(2029), + is_default: true, + enabled: true, + }) + .await?; + + let (sub_id, _items) = db + .insert_subscription_with_line_items( + &Subscription { + id: 0, + user_id, + company_id: 1, + name: "autorenew-test".to_string(), + description: None, + created: Utc::now(), + expires: None, + is_active: true, + is_setup: true, // already purchased -> Renewal + currency: "EUR".to_string(), + interval_amount: 1, + interval_type: IntervalType::Month, + setup_fee: 0, + auto_renewal_enabled: true, + external_id: None, + }, + vec![SubscriptionLineItem { + id: 0, + subscription_id: 0, + subscription_type: SubscriptionType::IpRange, + name: "hosting".to_string(), + description: None, + amount: 999, // €9.99 + setup_amount: 0, + configuration: None, + }], + ) + .await?; + + let sub = SubscriptionHandler::new( + settings, + db.clone(), + node.clone(), + Arc::new(MockExchangeRate::default()), + Arc::new(ChannelWorkCommander::new()), + VmStateCache::new(), + )?; + + let payment = sub.auto_renew_via_revolut(sub_id).await?; + + assert_eq!(payment.payment_method, PaymentMethod::Revolut); + assert_eq!(payment.currency, "EUR"); + assert_eq!(payment.amount, 999); + let ext_id = payment.external_id.expect("revolut order id"); + eprintln!("off-session charge order id = {ext_id}"); + + // The payment row should be persisted and unpaid (completed via webhook). + let stored = db.list_subscription_payments(sub_id).await?; + assert!(stored.iter().any(|p| p.id == payment.id)); + Ok(()) + } +} + +#[cfg(test)] +mod revolut_offline_tests { + use super::*; + use crate::mocks::MockNode; + use crate::settings::mock_settings; + use lnvps_api_common::{ChannelWorkCommander, MockDb, MockExchangeRate, VmStateCache}; + use lnvps_db::{ + IntervalType, LNVpsDbBase, Subscription, SubscriptionLineItem, UserPaymentMethod, + }; + use payments_rs::currency::CurrencyAmount; + use payments_rs::fiat::{FiatPaymentInfo, LineItem, SubscriptionPaymentInfo}; + use std::future::Future; + use std::pin::Pin; + use std::sync::Mutex; + + /// A FiatPaymentService mock that records calls for assertions. + #[derive(Default)] + struct MockFiat { + charged: Mutex>, + created_subscription: Mutex, + created_order: Mutex, + } + + impl FiatPaymentService for MockFiat { + fn create_order( + &self, + _d: &str, + _amount: CurrencyAmount, + _li: Option>, + ) -> Pin> + Send>> { + *self.created_order.lock().unwrap() = true; + Box::pin(async { + Ok(FiatPaymentInfo { + external_id: "order_mock".to_string(), + raw_data: "{}".to_string(), + }) + }) + } + fn cancel_order(&self, _id: &str) -> Pin> + Send>> { + Box::pin(async { Ok(()) }) + } + fn create_subscription( + &self, + _d: &str, + _a: CurrencyAmount, + _e: Option, + _li: Option>, + ) -> Pin> + Send>> { + *self.created_subscription.lock().unwrap() = true; + Box::pin(async { + Ok(SubscriptionPaymentInfo { + external_id: "sub_mock".to_string(), + customer_id: Some("cust_mock".to_string()), + payment_method_id: None, + checkout_url: Some("https://checkout".to_string()), + raw_data: "{}".to_string(), + }) + }) + } + fn charge_subscription( + &self, + customer_id: &str, + payment_method_id: &str, + amount: CurrencyAmount, + _d: &str, + ) -> Pin> + Send>> { + self.charged.lock().unwrap().push(( + customer_id.to_string(), + payment_method_id.to_string(), + amount.value(), + )); + Box::pin(async { + Ok(FiatPaymentInfo { + external_id: "charge_mock".to_string(), + raw_data: "{}".to_string(), + }) + }) + } + } + + fn mk_method(user_id: u64, cust: &str, pm: &str, default: bool, enabled: bool, exp: (u16, u16)) -> UserPaymentMethod { + UserPaymentMethod { + id: 0, + user_id, + created: Utc::now(), + provider: "revolut".to_string(), + name: None, + external_customer_id: Some(cust.to_string().into()), + external_id: pm.to_string().into(), + card_brand: Some("VISA".to_string()), + card_last_four: Some("5709".to_string()), + exp_month: Some(exp.1), + exp_year: Some(exp.0), + is_default: default, + enabled, + } + } + + async fn setup(auto_renew: bool) -> (Arc, SubscriptionHandler, u64, u64) { + let db = Arc::new(MockDb::default()); + let node = Arc::new(MockNode::default()); + let user_id = db.upsert_user(&[9u8; 32]).await.unwrap(); + let (sub_id, _items) = db + .insert_subscription_with_line_items( + &Subscription { + id: 0, + user_id, + company_id: 1, + name: "s".to_string(), + description: None, + created: Utc::now(), + expires: None, + is_active: true, + is_setup: true, + currency: "EUR".to_string(), + interval_amount: 1, + interval_type: IntervalType::Month, + setup_fee: 0, + auto_renewal_enabled: auto_renew, + external_id: None, + }, + vec![SubscriptionLineItem { + id: 0, + subscription_id: 0, + subscription_type: SubscriptionType::IpRange, + name: "hosting".to_string(), + description: None, + amount: 999, + setup_amount: 0, + configuration: None, + }], + ) + .await + .unwrap(); + let sub = SubscriptionHandler::new( + mock_settings(), + db.clone(), + node, + Arc::new(MockExchangeRate::default()), + Arc::new(ChannelWorkCommander::new()), + VmStateCache::new(), + ) + .unwrap(); + (db, sub, user_id, sub_id) + } + + #[tokio::test] + async fn test_default_revolut_payment_method_selection() { + let (db, sub, user_id, _sub_id) = setup(true).await; + + // No methods -> error + assert!(sub.default_revolut_payment_method(user_id).await.is_err()); + + // Default enabled non-expired method is chosen + let d = db + .insert_user_payment_method(&mk_method(user_id, "cA", "pA", true, true, (2999, 12))) + .await + .unwrap(); + let got = sub.default_revolut_payment_method(user_id).await.unwrap(); + assert_eq!(got.id, d); + + // Expire the default; add a non-default enabled non-expired one -> that is chosen + let mut expired = db.get_user_payment_method(d).await.unwrap(); + expired.exp_year = Some(2000); + db.update_user_payment_method(&expired).await.unwrap(); + let good = db + .insert_user_payment_method(&mk_method(user_id, "cB", "pB", false, true, (2999, 12))) + .await + .unwrap(); + assert_eq!(sub.default_revolut_payment_method(user_id).await.unwrap().id, good); + + // Disable all remaining -> error + let mut g = db.get_user_payment_method(good).await.unwrap(); + g.enabled = false; + db.update_user_payment_method(&g).await.unwrap(); + assert!(sub.default_revolut_payment_method(user_id).await.is_err()); + } + + #[tokio::test] + async fn test_auto_renew_via_revolut_offline() { + let (db, mut sub, user_id, sub_id) = setup(true).await; + db.insert_user_payment_method(&mk_method(user_id, "cust1", "pm1", true, true, (2999, 12))) + .await + .unwrap(); + let fiat = Arc::new(MockFiat::default()); + sub.set_revolut_for_test(fiat.clone()); + + let payment = sub.auto_renew_via_revolut(sub_id).await.unwrap(); + assert_eq!(payment.payment_method, PaymentMethod::Revolut); + assert_eq!(payment.currency, "EUR"); + assert_eq!(payment.amount, 999); + + let charged = fiat.charged.lock().unwrap(); + assert_eq!(charged.len(), 1); + assert_eq!(charged[0].0, "cust1"); + assert_eq!(charged[0].1, "pm1"); + + // Payment row persisted + assert!(db + .list_subscription_payments(sub_id) + .await + .unwrap() + .iter() + .any(|p| p.id == payment.id)); + } + + #[tokio::test] + async fn test_auto_renew_via_revolut_no_method_errors() { + let (_db, mut sub, _user_id, sub_id) = setup(true).await; + sub.set_revolut_for_test(Arc::new(MockFiat::default())); + assert!(sub.auto_renew_via_revolut(sub_id).await.is_err()); + } + + #[tokio::test] + async fn test_auto_renew_dispatches_by_provider() { + // Default method is a revolut card -> auto_renew dispatches to Revolut. + let (db, mut sub, user_id, sub_id) = setup(true).await; + db.insert_user_payment_method(&mk_method(user_id, "cust1", "pm1", true, true, (2999, 12))) + .await + .unwrap(); + let fiat = Arc::new(MockFiat::default()); + sub.set_revolut_for_test(fiat.clone()); + + let payment = sub.auto_renew(sub_id).await.unwrap(); + assert_eq!(payment.payment_method, PaymentMethod::Revolut); + assert_eq!(fiat.charged.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn test_default_payment_method_prefers_default_flag() { + // NWC is the default even though a revolut method also exists. + let (db, sub, user_id, _sub_id) = setup(true).await; + let nwc = UserPaymentMethod { + id: 0, + user_id, + created: Utc::now(), + provider: "nwc".to_string(), + name: None, + external_customer_id: None, + external_id: "nostr+walletconnect://x".to_string().into(), + card_brand: None, + card_last_four: None, + exp_month: None, + exp_year: None, + is_default: true, + enabled: true, + }; + db.insert_user_payment_method(&nwc).await.unwrap(); + db.insert_user_payment_method(&mk_method(user_id, "c", "p", false, true, (2999, 12))) + .await + .unwrap(); + + let got = sub.default_payment_method(user_id).await.unwrap(); + assert_eq!(got.provider, "nwc"); + } + + #[tokio::test] + async fn test_create_revolut_order_saves_when_no_method() { + // auto-renew on, no saved method -> create_subscription (savable checkout) + let (_db, mut sub, _user_id, sub_id) = setup(true).await; + let fiat = Arc::new(MockFiat::default()); + sub.set_revolut_for_test(fiat.clone()); + + sub.renew_subscription(sub_id, PaymentMethod::Revolut, 1) + .await + .unwrap(); + assert!(*fiat.created_subscription.lock().unwrap()); + assert!(!*fiat.created_order.lock().unwrap()); + } + + #[tokio::test] + async fn test_create_revolut_order_plain_when_method_exists() { + // A saved method already exists -> plain create_order (no re-save) + let (db, mut sub, user_id, sub_id) = setup(true).await; + db.insert_user_payment_method(&mk_method(user_id, "cust1", "pm1", true, true, (2999, 12))) + .await + .unwrap(); + let fiat = Arc::new(MockFiat::default()); + sub.set_revolut_for_test(fiat.clone()); + + sub.renew_subscription(sub_id, PaymentMethod::Revolut, 1) + .await + .unwrap(); + assert!(*fiat.created_order.lock().unwrap()); + assert!(!*fiat.created_subscription.lock().unwrap()); + } +} diff --git a/lnvps_api/src/worker.rs b/lnvps_api/src/worker.rs index 3d05ceda..71e9d054 100644 --- a/lnvps_api/src/worker.rs +++ b/lnvps_api/src/worker.rs @@ -255,54 +255,48 @@ impl Worker { && expires < expiry_window && expires > last_check.add(Days::new(BEFORE_EXPIRE_NOTIFICATION_DAYS)) { - // Track whether NWC auto-renewal was attempted and succeeded (so we skip the - // generic "expiring soon" notification below). + // Attempt auto-renewal using the user's default saved payment method + // (NWC Lightning wallet or Revolut card), dispatched by provider. let mut auto_renewed = false; - #[cfg(feature = "nostr-nwc")] if sub.auto_renewal_enabled { - let user = self.db.get_user(sub.user_id).await?; - if let Some(ref nwc_connection) = user.nwc_connection_string { - let nwc_string: String = nwc_connection.clone().into(); - if !nwc_string.is_empty() { - info!( - "Attempting auto-renewal for subscription {} via NWC", - sub.id - ); - match self - .subscription_handler - .auto_renew_via_nwc(sub.id, &nwc_string) - .await - { - Ok(_) => { - info!("Successfully auto-renewed subscription {} via NWC", sub.id); - self.queue_notification( - sub.user_id, - format!("Your subscription has been automatically renewed via Nostr Wallet Connect.\n{}", sub_notification_descr), - Some(format!("[{}] Auto-Renewed", sub_notification_subject)), - ).await; - auto_renewed = true; - } - Err(e) => { - warn!("Auto-renewal error for subscription {}: {}", sub.id, e); - self.queue_notification( - sub.user_id, - format!( - "Your subscription will expire soon.\nAutomatic renewal failed: '{}'\nPlease renew manually in the next {} day(s).\n{}", - e, BEFORE_EXPIRE_NOTIFICATION_DAYS, sub_notification_descr - ), - Some(format!("[{}] Expiring Soon", sub_notification_subject)), - ) - .await; - auto_renewed = true; - } + let has_method = self + .db + .list_user_payment_methods(sub.user_id, None) + .await + .map(|m| m.iter().any(|pm| pm.enabled)) + .unwrap_or(false); + if has_method { + info!("Attempting auto-renewal for subscription {}", sub.id); + match self.subscription_handler.auto_renew(sub.id).await { + Ok(_) => { + info!("Successfully auto-renewed subscription {}", sub.id); + self.queue_notification( + sub.user_id, + format!("Your subscription is being automatically renewed using your saved payment method.\n{}", sub_notification_descr), + Some(format!("[{}] Auto-Renewed", sub_notification_subject)), + ).await; + auto_renewed = true; + } + Err(e) => { + warn!("Auto-renewal error for subscription {}: {}", sub.id, e); + self.queue_notification( + sub.user_id, + format!( + "Your subscription will expire soon.\nAutomatic renewal failed: '{}'\nPlease renew manually in the next {} day(s).\n{}", + e, BEFORE_EXPIRE_NOTIFICATION_DAYS, sub_notification_descr + ), + Some(format!("[{}] Expiring Soon", sub_notification_subject)), + ) + .await; + auto_renewed = true; } } } } - // Send a plain expiry warning whenever NWC auto-renewal was not attempted - // (feature disabled, auto_renewal off, or no NWC string configured). + // Send a plain expiry warning whenever auto-renewal was not attempted + // (auto_renewal off, or no saved payment method). if !auto_renewed { self.queue_notification( sub.user_id, diff --git a/lnvps_api_admin/src/admin/model.rs b/lnvps_api_admin/src/admin/model.rs index 42d07de1..fde246ab 100644 --- a/lnvps_api_admin/src/admin/model.rs +++ b/lnvps_api_admin/src/admin/model.rs @@ -372,7 +372,8 @@ impl From for AdminUserInfo { vm_count: 0, last_login: None, is_admin: false, - has_nwc: user.nwc_connection_string.is_some(), + // Computed via a DB lookup (AdminUserInfo path); default false here. + has_nwc: false, } } } @@ -403,7 +404,7 @@ impl From for AdminUserInfo { vm_count: user.vm_count as _, last_login: None, is_admin: user.is_admin, - has_nwc: user.user_info.nwc_connection_string.is_some(), + has_nwc: user.has_nwc, } } } diff --git a/lnvps_api_admin/src/bin/generate_demo_data.rs b/lnvps_api_admin/src/bin/generate_demo_data.rs index 46a7c059..124a06b0 100644 --- a/lnvps_api_admin/src/bin/generate_demo_data.rs +++ b/lnvps_api_admin/src/bin/generate_demo_data.rs @@ -1092,7 +1092,6 @@ async fn create_users(db: &LNVpsDbMysql) -> Result> { billing_state: None, billing_postcode: None, billing_tax_id: None, - nwc_connection_string: None, email_hash: None, }; diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index be779a84..187d38ce 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -9,7 +9,8 @@ use lnvps_db::{ OsDistribution, PaymentMethod, PaymentMethodConfig, Referral, ReferralCostUsage, ReferralPayout, Router, RouterBgpRoute, RouterBgpSession, RouterTunnel, RouterTunnelTraffic, Subscription, SubscriptionLineItem, SubscriptionPayment, SubscriptionPaymentWithCompany, User, - UserSshKey, Vm, VmCostPlan, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, + UserPaymentMethod, UserSshKey, Vm, VmCostPlan, VmCustomPricing, VmCustomPricingDisk, + VmCustomTemplate, VmFirewallPolicy, VmFirewallRule, VmHistory, VmHost, VmHostDisk, VmHostKind, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate, }; @@ -29,6 +30,7 @@ pub struct MockDb { pub host_disks: Arc>>, pub users: Arc>>, pub user_ssh_keys: Arc>>, + pub user_payment_methods: Arc>>, pub cost_plans: Arc>>, pub os_images: Arc>>, pub templates: Arc>>, @@ -252,6 +254,7 @@ impl Default for MockDb { custom_pricing: Arc::new(Default::default()), custom_pricing_disk: Arc::new(Default::default()), user_ssh_keys: Arc::new(Mutex::new(Default::default())), + user_payment_methods: Arc::new(Default::default()), custom_template: Arc::new(Default::default()), payments: Arc::new(Default::default()), router: Arc::new(Default::default()), @@ -445,6 +448,51 @@ impl LNVpsDbBase for MockDb { Ok(users.len() as u64) } + async fn insert_user_payment_method(&self, pm: &UserPaymentMethod) -> DbResult { + let mut methods = self.user_payment_methods.lock().await; + let id = *methods.keys().max().unwrap_or(&0) + 1; + let mut new_pm = pm.clone(); + new_pm.id = id; + methods.insert(id, new_pm); + Ok(id) + } + + async fn list_user_payment_methods( + &self, + user_id: u64, + provider: Option<&str>, + ) -> DbResult> { + let methods = self.user_payment_methods.lock().await; + let mut out: Vec = methods + .values() + .filter(|m| m.user_id == user_id) + .filter(|m| provider.map(|p| m.provider == p).unwrap_or(true)) + .cloned() + .collect(); + out.sort_by(|a, b| b.is_default.cmp(&a.is_default).then(a.id.cmp(&b.id))); + Ok(out) + } + + async fn get_user_payment_method(&self, id: u64) -> DbResult { + let methods = self.user_payment_methods.lock().await; + methods + .get(&id) + .cloned() + .ok_or_else(|| DbError::from(anyhow!("Payment method not found"))) + } + + async fn update_user_payment_method(&self, pm: &UserPaymentMethod) -> DbResult<()> { + let mut methods = self.user_payment_methods.lock().await; + methods.insert(pm.id, pm.clone()); + Ok(()) + } + + async fn delete_user_payment_method(&self, id: u64) -> DbResult<()> { + let mut methods = self.user_payment_methods.lock().await; + methods.remove(&id); + Ok(()) + } + async fn insert_user_ssh_key(&self, new_key: &UserSshKey) -> DbResult { let mut ssh_keys = self.user_ssh_keys.lock().await; let max_keys = *ssh_keys.keys().max().unwrap_or(&0); @@ -2724,6 +2772,7 @@ impl lnvps_db::AdminDb for MockDb { user_info: u.clone(), vm_count: 0, is_admin: false, + has_nwc: false, }) .collect(); Ok((paginated_users, total)) @@ -3391,6 +3440,62 @@ mod tests { use super::*; use lnvps_db::{IntervalType, LNVpsDbBase, SubscriptionPaymentType}; + /// user_payment_method CRUD + provider filter via the mock DB. + #[tokio::test] + async fn test_user_payment_method_crud() { + use lnvps_db::UserPaymentMethod; + + let db = MockDb::default(); + let mk = |user_id: u64, provider: &str, default: bool| UserPaymentMethod { + id: 0, + user_id, + created: Utc::now(), + provider: provider.to_string(), + name: None, + external_customer_id: Some("cust".to_string().into()), + external_id: "pm".to_string().into(), + card_brand: Some("VISA".to_string()), + card_last_four: Some("5709".to_string()), + exp_month: Some(12), + exp_year: Some(2029), + is_default: default, + enabled: true, + }; + + // Insert two revolut methods (2nd is default) + one other provider + let id1 = db.insert_user_payment_method(&mk(1, "revolut", false)).await.unwrap(); + let id2 = db.insert_user_payment_method(&mk(1, "revolut", true)).await.unwrap(); + let _id3 = db.insert_user_payment_method(&mk(1, "stripe", false)).await.unwrap(); + assert_ne!(id1, id2); + + // Provider filter + default-first ordering + let revolut = db.list_user_payment_methods(1, Some("revolut")).await.unwrap(); + assert_eq!(revolut.len(), 2); + assert_eq!(revolut[0].id, id2, "default method should sort first"); + + // All providers for the user + let all = db.list_user_payment_methods(1, None).await.unwrap(); + assert_eq!(all.len(), 3); + + // Get one + let got = db.get_user_payment_method(id1).await.unwrap(); + assert_eq!(got.provider, "revolut"); + + // Update (disable + name it) + let mut upd = got.clone(); + upd.enabled = false; + upd.name = Some("My spare card".to_string()); + db.update_user_payment_method(&upd).await.unwrap(); + let after = db.get_user_payment_method(id1).await.unwrap(); + assert!(!after.enabled); + assert_eq!(after.name.as_deref(), Some("My spare card")); + + // Delete + db.delete_user_payment_method(id1).await.unwrap(); + assert!(db.get_user_payment_method(id1).await.is_err()); + assert_eq!(db.list_user_payment_methods(1, Some("revolut")).await.unwrap().len(), 1); + } + /// Build a minimal SubscriptionPayment for the default mock subscription (id=1). fn make_payment(subscription_id: u64, time_value: Option) -> SubscriptionPayment { SubscriptionPayment { diff --git a/lnvps_db/migrations/20260714144346_revolut_saved_payment_method.sql b/lnvps_db/migrations/20260714144346_revolut_saved_payment_method.sql new file mode 100644 index 00000000..afacd187 --- /dev/null +++ b/lnvps_db/migrations/20260714144346_revolut_saved_payment_method.sql @@ -0,0 +1,50 @@ +-- Unified saved payment methods for automatic renewals. +-- +-- Provider-agnostic (nwc, revolut now; stripe/paypal later). Stores only opaque +-- provider token references (encrypted) plus non-sensitive card metadata +-- (brand/last4/expiry) for display and expiry management. No PAN/CVV is stored. +-- +-- NWC (Nostr Wallet Connect) is modelled as a payment method too: external_id +-- holds the connection string and external_customer_id is NULL. This lets users +-- keep multiple methods and pick a default between their Lightning wallet (NWC) +-- and a saved card. +CREATE TABLE user_payment_method +( + id INTEGER UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id INTEGER UNSIGNED NOT NULL, + created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- Payment processor: 'nwc', 'revolut' + provider VARCHAR(20) NOT NULL, + -- Optional user-defined label to distinguish multiple methods + name VARCHAR(100) NULL, + -- Encrypted provider customer id (NULL for providers without one, e.g. nwc) + external_customer_id TEXT NULL, + -- Encrypted reusable token charged for renewals: Revolut payment method id, + -- or the NWC connection string + external_id TEXT NOT NULL, + -- Non-sensitive card metadata (PCI-safe) for display + expiry handling + card_brand VARCHAR(32) NULL, + card_last_four VARCHAR(4) NULL, + exp_month SMALLINT UNSIGNED NULL, + exp_year SMALLINT UNSIGNED NULL, + -- Whether this is the user's default method + is_default BIT(1) NOT NULL DEFAULT 0, + -- Whether this method is usable (disabled when expired/revoked) + enabled BIT(1) NOT NULL DEFAULT 1, + CONSTRAINT fk_user_payment_method_user + FOREIGN KEY (user_id) REFERENCES users (id), + INDEX idx_user_payment_method_user (user_id, provider, enabled) +) DEFAULT CHARSET = utf8mb4; + +-- Migrate existing NWC connection strings into the unified table. The stored +-- value (encrypted ciphertext or plaintext) is copied verbatim; the +-- EncryptedString decoder transparently handles either form on read. +INSERT INTO user_payment_method (user_id, provider, external_id, is_default, enabled) +SELECT id, 'nwc', nwc_connection_string, 1, 1 +FROM users +WHERE nwc_connection_string IS NOT NULL + AND nwc_connection_string != ''; + +-- NWC is now represented as a payment method; drop the legacy column. +ALTER TABLE users + DROP COLUMN nwc_connection_string; diff --git a/lnvps_db/src/lib.rs b/lnvps_db/src/lib.rs index 78a796c7..1cea5442 100644 --- a/lnvps_db/src/lib.rs +++ b/lnvps_db/src/lib.rs @@ -135,6 +135,25 @@ pub trait LNVpsDbBase: Send + Sync { /// Get total count of users async fn count_users(&self) -> DbResult; + /// Insert a saved payment method for a user; returns the new id + async fn insert_user_payment_method(&self, pm: &UserPaymentMethod) -> DbResult; + + /// List a user's saved payment methods (optionally filtered to one provider) + async fn list_user_payment_methods( + &self, + user_id: u64, + provider: Option<&str>, + ) -> DbResult>; + + /// Get a single saved payment method by id + async fn get_user_payment_method(&self, id: u64) -> DbResult; + + /// Update a saved payment method (enabled/default/card metadata) + async fn update_user_payment_method(&self, pm: &UserPaymentMethod) -> DbResult<()>; + + /// Delete a saved payment method by id + async fn delete_user_payment_method(&self, id: u64) -> DbResult<()>; + /// Insert a new user ssh key async fn insert_user_ssh_key(&self, new_key: &UserSshKey) -> DbResult; diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index 8687d441..519276fc 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -62,8 +62,45 @@ pub struct User { pub billing_postcode: Option, /// Billing tax id pub billing_tax_id: Option, - /// Nostr Wallet Connect connection string for automatic renewals (encrypted) - pub nwc_connection_string: Option, +} + +/// A saved payment method for off-session (merchant-initiated) automatic +/// renewals. Provider-agnostic. Stores only opaque provider token references +/// (encrypted) plus non-sensitive card metadata (brand/last4/expiry) — never +/// card PAN/CVV. +#[derive(FromRow, Clone, Debug, Default)] +pub struct UserPaymentMethod { + pub id: u64, + pub user_id: u64, + pub created: DateTime, + /// Payment processor (e.g. `revolut`) + pub provider: String, + /// Optional user-defined label to distinguish multiple methods + pub name: Option, + /// Encrypted provider customer id owning the saved method (None for + /// providers without one, e.g. NWC) + pub external_customer_id: Option, + /// Encrypted reusable payment method id charged off-session + pub external_id: EncryptedString, + pub card_brand: Option, + pub card_last_four: Option, + pub exp_month: Option, + pub exp_year: Option, + /// Default method for this provider + pub is_default: bool, + /// Whether this method is usable (disabled when expired/revoked) + pub enabled: bool, +} + +impl UserPaymentMethod { + /// Whether the card has expired as of the given year/month (1-based month). + /// Methods without expiry data are treated as non-expiring. + pub fn is_expired(&self, year: u16, month: u16) -> bool { + match (self.exp_year, self.exp_month) { + (Some(ey), Some(em)) => (ey, em) < (year, month), + _ => false, + } + } } #[derive(FromRow, Clone, Debug, Default)] @@ -82,6 +119,8 @@ pub struct AdminUserInfo { // Admin-specific fields pub vm_count: i64, pub is_admin: bool, + /// Whether the user has an NWC payment method configured (computed) + pub has_nwc: bool, } #[derive(Clone, Debug, sqlx::Type, Default, PartialEq, Eq)] @@ -1946,6 +1985,30 @@ impl InternetRegistry { mod tests { use super::*; + #[test] + fn test_user_payment_method_is_expired() { + let mut pm = UserPaymentMethod { + exp_year: Some(2029), + exp_month: Some(12), + ..Default::default() + }; + // Before expiry + assert!(!pm.is_expired(2029, 11)); + // Same month is not yet expired + assert!(!pm.is_expired(2029, 12)); + // After expiry month + assert!(pm.is_expired(2030, 1)); + assert!(pm.is_expired(2029, 12) == false); + // Earlier year + assert!(!pm.is_expired(2028, 12)); + // Missing expiry data -> never expired + pm.exp_year = None; + assert!(!pm.is_expired(2030, 1)); + pm.exp_year = Some(2029); + pm.exp_month = None; + assert!(!pm.is_expired(2030, 1)); + } + #[test] fn test_internet_registry_min_prefix_sizes() { // Test that all RIRs return correct minimum prefix sizes diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index f6e2e91a..c2045dd9 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -3,7 +3,8 @@ use crate::{ IpRangeSubscription, IpSpacePricing, LNVpsDbBase, PaymentMethod, PaymentMethodConfig, PaymentType, Referral, ReferralCostUsage, ReferralPayout, RegionStats, Router, RouterBgpRoute, RouterBgpSession, RouterTunnel, RouterTunnelTraffic, Subscription, SubscriptionLineItem, - SubscriptionPayment, SubscriptionPaymentWithCompany, User, UserSshKey, Vm, VmCostPlan, + SubscriptionPayment, SubscriptionPaymentWithCompany, User, UserPaymentMethod, UserSshKey, Vm, + VmCostPlan, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmFirewallPolicy, VmFirewallRule, VmForMigration, VmHistory, VmHost, VmHostDisk, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmPaymentRaw, VmTemplate, @@ -216,7 +217,7 @@ impl LNVpsDbBase for LNVpsDbMysql { Some(crate::email_hash(user.email.as_str()).to_vec()) }; sqlx::query( - "update users set email=?, email_hash=?, email_verified=?, email_verify_token=?, contact_nip17=?, contact_email=?, contact_telegram=?, telegram_chat_id=?, telegram_link_token=?, contact_whatsapp=?, whatsapp_number=?, whatsapp_verified=?, whatsapp_verify_code=?, country_code=?, billing_name=?, billing_address_1=?, billing_address_2=?, billing_city=?, billing_state=?, billing_postcode=?, billing_tax_id=?, nwc_connection_string=? where id = ?", + "update users set email=?, email_hash=?, email_verified=?, email_verify_token=?, contact_nip17=?, contact_email=?, contact_telegram=?, telegram_chat_id=?, telegram_link_token=?, contact_whatsapp=?, whatsapp_number=?, whatsapp_verified=?, whatsapp_verify_code=?, country_code=?, billing_name=?, billing_address_1=?, billing_address_2=?, billing_city=?, billing_state=?, billing_postcode=?, billing_tax_id=? where id = ?", ) .bind(&user.email) .bind(hash) @@ -239,7 +240,6 @@ impl LNVpsDbBase for LNVpsDbMysql { .bind(&user.billing_state) .bind(&user.billing_postcode) .bind(&user.billing_tax_id) - .bind(&user.nwc_connection_string) .bind(user.id) .execute(&self.db) .await?; @@ -317,6 +317,81 @@ impl LNVpsDbBase for LNVpsDbMysql { .try_get(0)?) } + async fn insert_user_payment_method(&self, pm: &UserPaymentMethod) -> DbResult { + Ok(sqlx::query( + "insert into user_payment_method(user_id,provider,name,external_customer_id,external_id,card_brand,card_last_four,exp_month,exp_year,is_default,enabled) values(?,?,?,?,?,?,?,?,?,?,?) returning id", + ) + .bind(pm.user_id) + .bind(&pm.provider) + .bind(&pm.name) + .bind(&pm.external_customer_id) + .bind(&pm.external_id) + .bind(&pm.card_brand) + .bind(&pm.card_last_four) + .bind(pm.exp_month) + .bind(pm.exp_year) + .bind(pm.is_default) + .bind(pm.enabled) + .fetch_one(&self.db) + .await? + .try_get(0)?) + } + + async fn list_user_payment_methods( + &self, + user_id: u64, + provider: Option<&str>, + ) -> DbResult> { + Ok(if let Some(provider) = provider { + sqlx::query_as( + "select * from user_payment_method where user_id=? and provider=? order by is_default desc, id asc", + ) + .bind(user_id) + .bind(provider) + .fetch_all(&self.db) + .await? + } else { + sqlx::query_as( + "select * from user_payment_method where user_id=? order by is_default desc, id asc", + ) + .bind(user_id) + .fetch_all(&self.db) + .await? + }) + } + + async fn get_user_payment_method(&self, id: u64) -> DbResult { + Ok(sqlx::query_as("select * from user_payment_method where id=?") + .bind(id) + .fetch_one(&self.db) + .await?) + } + + async fn update_user_payment_method(&self, pm: &UserPaymentMethod) -> DbResult<()> { + sqlx::query( + "update user_payment_method set name=?,card_brand=?,card_last_four=?,exp_month=?,exp_year=?,is_default=?,enabled=? where id=?", + ) + .bind(&pm.name) + .bind(&pm.card_brand) + .bind(&pm.card_last_four) + .bind(pm.exp_month) + .bind(pm.exp_year) + .bind(pm.is_default) + .bind(pm.enabled) + .bind(pm.id) + .execute(&self.db) + .await?; + Ok(()) + } + + async fn delete_user_payment_method(&self, id: u64) -> DbResult<()> { + sqlx::query("delete from user_payment_method where id=?") + .bind(id) + .execute(&self.db) + .await?; + Ok(()) + } + async fn insert_user_ssh_key(&self, new_key: &UserSshKey) -> DbResult { Ok(sqlx::query( "insert into user_ssh_key(name,user_id,key_data) values(?, ?, ?) returning id", @@ -1796,8 +1871,7 @@ impl LNVpsDbBase for LNVpsDbMysql { u.billing_city, u.billing_state, u.billing_postcode, - u.billing_tax_id, - u.nwc_connection_string + u.billing_tax_id FROM users u INNER JOIN vm ON u.id = vm.user_id WHERE vm.deleted = 0 @@ -3677,7 +3751,7 @@ impl AdminDb for LNVpsDbMysql { u.billing_state, u.billing_postcode, u.billing_tax_id, - u.nwc_connection_string, + EXISTS(SELECT 1 FROM user_payment_method pm WHERE pm.user_id = u.id AND pm.provider = 'nwc' AND pm.enabled = 1) as has_nwc, COALESCE(vm_stats.vm_count, 0) as vm_count, CASE WHEN admin_roles.user_id IS NOT NULL THEN 1 ELSE 0 END as is_admin FROM users u @@ -3757,7 +3831,7 @@ impl AdminDb for LNVpsDbMysql { u.billing_state, u.billing_postcode, u.billing_tax_id, - u.nwc_connection_string, + EXISTS(SELECT 1 FROM user_payment_method pm WHERE pm.user_id = u.id AND pm.provider = 'nwc' AND pm.enabled = 1) as has_nwc, COALESCE(vm_stats.vm_count, 0) as vm_count, CASE WHEN admin_roles.user_id IS NOT NULL THEN 1 ELSE 0 END as is_admin FROM users u