From f2efc86f64fa4139a955ed48bee995f810c6835e Mon Sep 17 00:00:00 2001 From: v0l Date: Tue, 14 Jul 2026 18:39:23 +0100 Subject: [PATCH 1/3] feat: automatic renewal via saved Revolut card (off-session) Adds Revolut merchant-initiated (off-session) automatic renewals alongside the existing Lightning/NWC path. When a customer completes a Revolut checkout while auto-renewal is enabled, the card is saved (savePaymentMethodFor=merchant) and its opaque token references + non-sensitive card metadata (brand/last4/expiry) are captured on webhook completion into a new provider-agnostic user_payment_method table. The worker then charges the default saved method off-session when a subscription is expiring. - DB: user_payment_method table (provider-agnostic, default/enabled/expiry) + model, mysql + mock impls, migration - Capture: RevolutPaymentHandler fetches the merchant-capable saved method + card details from the customer endpoint, dedupes, first method becomes default - Charge: SubscriptionHandler::auto_renew_via_revolut selects the default enabled, non-expired method and charges via charge_subscription - Worker: Revolut fallback branch after NWC in handle_subscription_state (behind the revolut feature) - API: read-only revolut_payment_method_saved on GET /api/v1/account - Bumps payments-rs to include the Revolut saved-method fixes - examples/revolut_sandbox.rs + revolut_widget.html: sandbox test harness - ignored integration test validating the off-session charge end-to-end Only opaque provider token references are stored (encrypted at rest); no card PAN/CVV ever touches the DB. Fixes #159 --- API_CHANGELOG.md | 9 + Cargo.lock | 2 +- Cargo.toml | 2 +- lnvps_api/examples/revolut_sandbox.rs | 176 +++++++++++ lnvps_api/examples/revolut_widget.html | 41 +++ lnvps_api/src/api/model.rs | 6 + lnvps_api/src/api/routes.rs | 12 +- lnvps_api/src/payments/revolut.rs | 80 ++++- lnvps_api/src/subscription/mod.rs | 290 +++++++++++++++++- lnvps_api/src/worker.rs | 52 +++- lnvps_api_common/src/mock.rs | 50 ++- ...714144346_revolut_saved_payment_method.sql | 29 ++ lnvps_db/src/lib.rs | 19 ++ lnvps_db/src/model.rs | 36 +++ lnvps_db/src/mysql.rs | 76 ++++- 15 files changed, 856 insertions(+), 24 deletions(-) create mode 100644 lnvps_api/examples/revolut_sandbox.rs create mode 100644 lnvps_api/examples/revolut_widget.html create mode 100644 lnvps_db/migrations/20260714144346_revolut_saved_payment_method.sql diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index 2c99bd9c..818c2ded 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -4,6 +4,15 @@ 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** - Automatic renewal via saved Revolut card (off-session) + - Subscriptions with `auto_renewal_enabled` can now be renewed automatically by charging a saved Revolut payment method off-session, in addition to the existing Lightning/NWC auto-renewal. Closes #159. + - The customer's Revolut payment method is saved automatically the next time they complete a Revolut checkout while auto-renewal is enabled (no separate setup step). Saved methods are stored in a new provider-agnostic `user_payment_method` table (one-to-many per user, with a default method and enabled flag). Only opaque provider token references (`customer_id`, `payment_method_id`) are stored, encrypted at rest, alongside non-sensitive card metadata (brand, last 4, expiry) for display and expiry handling — never card PAN/CVV. + - `GET /api/v1/account` now returns a read-only `revolut_payment_method_saved` boolean indicating whether a usable saved Revolut method is on file for automatic renewals. Tokens are never exposed. + ## [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..8023bc3f 100644 --- a/lnvps_api/src/api/model.rs +++ b/lnvps_api/src/api/model.rs @@ -144,6 +144,10 @@ pub struct AccountPatchRequest { deserialize_with = "lnvps_api_common::deserialize_nullable_option" )] pub nwc_connection_string: Option>, + /// Whether a Revolut payment method is saved for off-session automatic + /// renewals (read-only; the token itself is never exposed). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revolut_payment_method_saved: Option, } impl From for AccountPatchRequest { @@ -173,6 +177,8 @@ impl From for AccountPatchRequest { postcode: Some(user.billing_postcode), tax_id: Some(user.billing_tax_id), nwc_connection_string: Some(user.nwc_connection_string.map(|nwc| nwc.into())), + // Populated by the account handler (needs a DB lookup). + revolut_payment_method_saved: None, } } } diff --git a/lnvps_api/src/api/routes.rs b/lnvps_api/src/api/routes.rs index 76ae7e95..d88bd2e6 100644 --- a/lnvps_api/src/api/routes.rs +++ b/lnvps_api/src/api/routes.rs @@ -350,7 +350,17 @@ async fn v1_get_account( let uid = this.db.upsert_user(&pubkey).await?; let user = this.db.get_user(uid).await?; - ApiData::ok(user.into()) + let mut account: AccountPatchRequest = user.into(); + // Whether the user has a usable saved Revolut method for automatic renewals. + let has_saved_method = this + .db + .list_user_payment_methods(uid, Some("revolut")) + .await + .map(|m| m.iter().any(|pm| pm.enabled)) + .unwrap_or(false); + account.revolut_payment_method_saved = Some(has_saved_method); + + ApiData::ok(account) } /// Notification channels configured on this server. The UI can use this to diff --git a/lnvps_api/src/payments/revolut.rs b/lnvps_api/src/payments/revolut.rs index 0222b1a3..088c38eb 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,57 @@ 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(), + external_customer_id: 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 +231,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 +248,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..db5ac985 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}; @@ -241,12 +241,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 +551,33 @@ 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().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 +590,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 +708,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 +734,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), @@ -681,6 +792,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 +858,139 @@ 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(), + external_customer_id: 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(()) + } +} diff --git a/lnvps_api/src/worker.rs b/lnvps_api/src/worker.rs index 3d05ceda..59212292 100644 --- a/lnvps_api/src/worker.rs +++ b/lnvps_api/src/worker.rs @@ -301,8 +301,56 @@ impl Worker { } } - // Send a plain expiry warning whenever NWC auto-renewal was not attempted - // (feature disabled, auto_renewal off, or no NWC string configured). + // Revolut fallback: if NWC auto-renewal did not run but the user has a + // saved Revolut payment method, charge it off-session. + #[cfg(feature = "revolut")] + if !auto_renewed && sub.auto_renewal_enabled { + let has_saved_method = self + .db + .list_user_payment_methods(sub.user_id, Some("revolut")) + .await + .map(|m| m.iter().any(|pm| pm.enabled)) + .unwrap_or(false); + if has_saved_method { + info!( + "Attempting auto-renewal for subscription {} via Revolut saved card", + sub.id + ); + match self.subscription_handler.auto_renew_via_revolut(sub.id).await { + Ok(_) => { + info!( + "Successfully submitted Revolut auto-renewal for subscription {}", + sub.id + ); + self.queue_notification( + sub.user_id, + format!("Your subscription is being automatically renewed using your saved card.\n{}", sub_notification_descr), + Some(format!("[{}] Auto-Renewed", sub_notification_subject)), + ).await; + auto_renewed = true; + } + Err(e) => { + warn!( + "Revolut 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 auto-renewal was not attempted + // (feature disabled, auto_renewal off, or no saved payment method). if !auto_renewed { self.queue_notification( sub.user_id, diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index be779a84..adba7626 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); 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..c9a65b83 --- /dev/null +++ b/lnvps_db/migrations/20260714144346_revolut_saved_payment_method.sql @@ -0,0 +1,29 @@ +-- Saved payment methods for off-session (merchant-initiated) automatic renewals. +-- +-- Provider-agnostic (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. +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: e.g. 'revolut' + provider VARCHAR(20) NOT NULL, + -- Encrypted provider customer id owning the saved method + external_customer_id TEXT NOT NULL, + -- Encrypted reusable payment method id charged off-session + 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 for this provider + 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; 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..7b177c99 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -66,6 +66,42 @@ pub struct User { 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, + /// Encrypted provider customer id owning the saved method + pub external_customer_id: EncryptedString, + /// 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)] pub struct UserSshKey { pub id: u64, diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index f6e2e91a..90b1e0c9 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, @@ -317,6 +318,79 @@ 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,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.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 card_brand=?,card_last_four=?,exp_month=?,exp_year=?,is_default=?,enabled=? where 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) + .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", From ed15b66732e7ff52b9e6b6b71346eb56a189ea6f Mon Sep 17 00:00:00 2001 From: v0l Date: Tue, 14 Jul 2026 19:05:50 +0100 Subject: [PATCH 2/3] refactor: unify NWC + Revolut under user_payment_method with default selection Reworks saved payment methods into a single user-selectable model: - NWC (Nostr Wallet Connect) is now a payment method too. Existing users.nwc_connection_string values are migrated into user_payment_method (provider='nwc') and the column is dropped. - external_customer_id is nullable (NWC has none). - Auto-renewal now charges the user's DEFAULT enabled method, dispatched by provider (NWC Lightning wallet or Revolut card off-session). The worker's separate NWC/Revolut branches are replaced by a single auto_renew() call. - New user-facing payment-methods API: - GET /api/v1/payment-methods list (masked; no tokens/NWC strings) - POST /api/v1/payment-methods add an NWC connection (validated) - PATCH /api/v1/payment-methods/{id} set default / enable-disable - DELETE /api/v1/payment-methods/{id} remove - Removes nwc_connection_string and revolut_payment_method_saved from the account object; NWC is managed via the payment-methods API. Admin has_nwc is computed via a subquery. Adds offline unit tests (default selection, provider dispatch, mock FiatPaymentService charge, NWC/card CRUD) plus UserPaymentMethod::is_expired. --- API_CHANGELOG.md | 14 +- lnvps_api/src/api/model.rs | 59 ++- lnvps_api/src/api/referral.rs | 25 +- lnvps_api/src/api/routes.rs | 180 ++++++--- lnvps_api/src/payments/revolut.rs | 2 +- lnvps_api/src/subscription/mod.rs | 359 +++++++++++++++++- lnvps_api/src/worker.rs | 76 +--- lnvps_api_admin/src/admin/model.rs | 5 +- lnvps_api_admin/src/bin/generate_demo_data.rs | 1 - lnvps_api_common/src/mock.rs | 53 +++ ...714144346_revolut_saved_payment_method.sql | 33 +- lnvps_db/src/model.rs | 33 +- lnvps_db/src/mysql.rs | 10 +- 13 files changed, 677 insertions(+), 173 deletions(-) diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index 818c2ded..4f9db9c7 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -8,10 +8,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added -- **2026-07-14** - Automatic renewal via saved Revolut card (off-session) - - Subscriptions with `auto_renewal_enabled` can now be renewed automatically by charging a saved Revolut payment method off-session, in addition to the existing Lightning/NWC auto-renewal. Closes #159. - - The customer's Revolut payment method is saved automatically the next time they complete a Revolut checkout while auto-renewal is enabled (no separate setup step). Saved methods are stored in a new provider-agnostic `user_payment_method` table (one-to-many per user, with a default method and enabled flag). Only opaque provider token references (`customer_id`, `payment_method_id`) are stored, encrypted at rest, alongside non-sensitive card metadata (brand, last 4, expiry) for display and expiry handling — never card PAN/CVV. - - `GET /api/v1/account` now returns a read-only `revolut_payment_method_saved` boolean indicating whether a usable saved Revolut method is on file for automatic renewals. Tokens are never exposed. +- **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`, `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 }`); validated for `pay_invoice` support. + - `PATCH /api/v1/payment-methods/{id}` — 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 diff --git a/lnvps_api/src/api/model.rs b/lnvps_api/src/api/model.rs index 8023bc3f..bcf075d8 100644 --- a/lnvps_api/src/api/model.rs +++ b/lnvps_api/src/api/model.rs @@ -137,17 +137,51 @@ pub struct AccountPatchRequest { deserialize_with = "lnvps_api_common::deserialize_nullable_option" )] pub tax_id: Option>, - /// Nostr Wallet Connect connection string for automatic VM renewals - #[serde( - default, - skip_serializing_if = "Option::is_none", - deserialize_with = "lnvps_api_common::deserialize_nullable_option" - )] - pub nwc_connection_string: Option>, - /// Whether a Revolut payment method is saved for off-session automatic - /// renewals (read-only; the token itself is never exposed). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub revolut_payment_method_saved: Option, +} + +/// 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, + 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, + 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, +} + +/// Update a saved payment method (set default / enable-disable). +#[derive(Deserialize)] +pub struct PatchPaymentMethodRequest { + pub is_default: Option, + pub enabled: Option, } impl From for AccountPatchRequest { @@ -176,9 +210,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())), - // Populated by the account handler (needs a DB lookup). - revolut_payment_method_saved: None, } } } 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 d88bd2e6..4b7ad940 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,18 +330,119 @@ 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()) +} - let mut account: AccountPatchRequest = user.into(); - // Whether the user has a usable saved Revolut method for automatic renewals. - let has_saved_method = this - .db - .list_user_payment_methods(uid, Some("revolut")) - .await - .map(|m| m.iter().any(|pm| pm.enabled)) - .unwrap_or(false); - account.revolut_payment_method_saved = Some(has_saved_method); +/// 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)), + } - ApiData::ok(account) + // 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(), + 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 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 @@ -867,20 +949,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 088c38eb..1e99205b 100644 --- a/lnvps_api/src/payments/revolut.rs +++ b/lnvps_api/src/payments/revolut.rs @@ -202,7 +202,7 @@ impl RevolutPaymentHandler { user_id, created: Utc::now(), provider: "revolut".to_string(), - external_customer_id: customer_id.to_string().into(), + 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()), diff --git a/lnvps_api/src/subscription/mod.rs b/lnvps_api/src/subscription/mod.rs index db5ac985..3ebc10de 100644 --- a/lnvps_api/src/subscription/mod.rs +++ b/lnvps_api/src/subscription/mod.rs @@ -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, @@ -556,7 +561,11 @@ impl SubscriptionHandler { // 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().into(); + 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( @@ -758,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) @@ -782,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); @@ -928,7 +981,7 @@ mod revolut_autorenew_tests { user_id, created: Utc::now(), provider: "revolut".to_string(), - external_customer_id: customer_id.into(), + 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()), @@ -994,3 +1047,289 @@ mod revolut_autorenew_tests { 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(), + 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(), + 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 59212292..71e9d054 100644 --- a/lnvps_api/src/worker.rs +++ b/lnvps_api/src/worker.rs @@ -255,85 +255,31 @@ 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; - } - } - } - } - } - - // Revolut fallback: if NWC auto-renewal did not run but the user has a - // saved Revolut payment method, charge it off-session. - #[cfg(feature = "revolut")] - if !auto_renewed && sub.auto_renewal_enabled { - let has_saved_method = self + let has_method = self .db - .list_user_payment_methods(sub.user_id, Some("revolut")) + .list_user_payment_methods(sub.user_id, None) .await .map(|m| m.iter().any(|pm| pm.enabled)) .unwrap_or(false); - if has_saved_method { - info!( - "Attempting auto-renewal for subscription {} via Revolut saved card", - sub.id - ); - match self.subscription_handler.auto_renew_via_revolut(sub.id).await { + if has_method { + info!("Attempting auto-renewal for subscription {}", sub.id); + match self.subscription_handler.auto_renew(sub.id).await { Ok(_) => { - info!( - "Successfully submitted Revolut auto-renewal for subscription {}", - sub.id - ); + info!("Successfully auto-renewed subscription {}", sub.id); self.queue_notification( sub.user_id, - format!("Your subscription is being automatically renewed using your saved card.\n{}", sub_notification_descr), + 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!( - "Revolut auto-renewal error for subscription {}: {}", - sub.id, e - ); + warn!("Auto-renewal error for subscription {}: {}", sub.id, e); self.queue_notification( sub.user_id, format!( @@ -350,7 +296,7 @@ impl Worker { } // Send a plain expiry warning whenever auto-renewal was not attempted - // (feature disabled, auto_renewal off, or no saved payment method). + // (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 adba7626..d138ad7f 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -2772,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)) @@ -3439,6 +3440,58 @@ 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(), + 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 it) + let mut upd = got.clone(); + upd.enabled = false; + db.update_user_payment_method(&upd).await.unwrap(); + assert!(!db.get_user_payment_method(id1).await.unwrap().enabled); + + // 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 index c9a65b83..dca92240 100644 --- a/lnvps_db/migrations/20260714144346_revolut_saved_payment_method.sql +++ b/lnvps_db/migrations/20260714144346_revolut_saved_payment_method.sql @@ -1,25 +1,31 @@ --- Saved payment methods for off-session (merchant-initiated) automatic renewals. +-- Unified saved payment methods for automatic renewals. -- --- Provider-agnostic (revolut now, stripe/paypal later). Stores only opaque +-- 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: e.g. 'revolut' + -- Payment processor: 'nwc', 'revolut' provider VARCHAR(20) NOT NULL, - -- Encrypted provider customer id owning the saved method - external_customer_id TEXT NOT NULL, - -- Encrypted reusable payment method id charged off-session + -- 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 for this provider + -- 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, @@ -27,3 +33,16 @@ CREATE TABLE user_payment_method 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/model.rs b/lnvps_db/src/model.rs index 7b177c99..8dc781f3 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -62,8 +62,6 @@ 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 @@ -77,8 +75,9 @@ pub struct UserPaymentMethod { pub created: DateTime, /// Payment processor (e.g. `revolut`) pub provider: String, - /// Encrypted provider customer id owning the saved method - pub external_customer_id: EncryptedString, + /// 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, @@ -118,6 +117,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)] @@ -1982,6 +1983,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 90b1e0c9..3f1eb31f 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -217,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) @@ -240,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?; @@ -1870,8 +1869,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 @@ -3751,7 +3749,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 @@ -3831,7 +3829,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 From eaa27df410b968045b94d219a2f23c8b98fbe84b Mon Sep 17 00:00:00 2001 From: v0l Date: Tue, 14 Jul 2026 19:49:01 +0100 Subject: [PATCH 3/3] feat: optional user-defined name on saved payment methods Adds a nullable name column to user_payment_method so users can label multiple methods (e.g. 'Personal Visa', 'Backup wallet'). Exposed on GET /api/v1/payment-methods, settable via POST (name?) and PATCH (name). Refs #85 --- API_CHANGELOG.md | 6 +++--- lnvps_api/src/api/model.rs | 14 +++++++++++++- lnvps_api/src/api/routes.rs | 7 +++++++ lnvps_api/src/payments/revolut.rs | 1 + lnvps_api/src/subscription/mod.rs | 3 +++ lnvps_api_common/src/mock.rs | 8 ++++++-- ...20260714144346_revolut_saved_payment_method.sql | 2 ++ lnvps_db/src/model.rs | 2 ++ lnvps_db/src/mysql.rs | 6 ++++-- 9 files changed, 41 insertions(+), 8 deletions(-) diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index 4f9db9c7..fae154f1 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -13,9 +13,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - 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`, `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 }`); validated for `pay_invoice` support. - - `PATCH /api/v1/payment-methods/{id}` — set as default (`is_default`) and/or enable-disable (`enabled`). + - `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. diff --git a/lnvps_api/src/api/model.rs b/lnvps_api/src/api/model.rs index bcf075d8..faef666d 100644 --- a/lnvps_api/src/api/model.rs +++ b/lnvps_api/src/api/model.rs @@ -146,6 +146,8 @@ 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, @@ -160,6 +162,7 @@ impl From for PaymentMethodResponse { 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, @@ -175,13 +178,22 @@ impl From for PaymentMethodResponse { #[derive(Deserialize)] pub struct AddNwcPaymentMethodRequest { pub nwc_connection_string: String, + /// Optional user-defined label + pub name: Option, } -/// Update a saved payment method (set default / enable-disable). +/// 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 name: Option>, } impl From for AccountPatchRequest { diff --git a/lnvps_api/src/api/routes.rs b/lnvps_api/src/api/routes.rs index 4b7ad940..49aa27f1 100644 --- a/lnvps_api/src/api/routes.rs +++ b/lnvps_api/src/api/routes.rs @@ -381,6 +381,7 @@ async fn v1_add_nwc_payment_method( 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, @@ -413,6 +414,12 @@ async fn v1_patch_payment_method( 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? { diff --git a/lnvps_api/src/payments/revolut.rs b/lnvps_api/src/payments/revolut.rs index 1e99205b..641d0bed 100644 --- a/lnvps_api/src/payments/revolut.rs +++ b/lnvps_api/src/payments/revolut.rs @@ -202,6 +202,7 @@ impl RevolutPaymentHandler { 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()), diff --git a/lnvps_api/src/subscription/mod.rs b/lnvps_api/src/subscription/mod.rs index 3ebc10de..70f6b344 100644 --- a/lnvps_api/src/subscription/mod.rs +++ b/lnvps_api/src/subscription/mod.rs @@ -981,6 +981,7 @@ mod revolut_autorenew_tests { 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()), @@ -1134,6 +1135,7 @@ mod revolut_offline_tests { 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()), @@ -1284,6 +1286,7 @@ mod revolut_offline_tests { 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, diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index d138ad7f..187d38ce 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -3451,6 +3451,7 @@ mod tests { 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()), @@ -3480,11 +3481,14 @@ mod tests { let got = db.get_user_payment_method(id1).await.unwrap(); assert_eq!(got.provider, "revolut"); - // Update (disable it) + // 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(); - assert!(!db.get_user_payment_method(id1).await.unwrap().enabled); + 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(); diff --git a/lnvps_db/migrations/20260714144346_revolut_saved_payment_method.sql b/lnvps_db/migrations/20260714144346_revolut_saved_payment_method.sql index dca92240..afacd187 100644 --- a/lnvps_db/migrations/20260714144346_revolut_saved_payment_method.sql +++ b/lnvps_db/migrations/20260714144346_revolut_saved_payment_method.sql @@ -15,6 +15,8 @@ CREATE TABLE user_payment_method 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, diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index 8dc781f3..519276fc 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -75,6 +75,8 @@ pub struct UserPaymentMethod { 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, diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index 3f1eb31f..c2045dd9 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -319,10 +319,11 @@ impl LNVpsDbBase for LNVpsDbMysql { async fn insert_user_payment_method(&self, pm: &UserPaymentMethod) -> DbResult { Ok(sqlx::query( - "insert into user_payment_method(user_id,provider,external_customer_id,external_id,card_brand,card_last_four,exp_month,exp_year,is_default,enabled) values(?,?,?,?,?,?,?,?,?,?) returning id", + "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) @@ -368,8 +369,9 @@ impl LNVpsDbBase for LNVpsDbMysql { async fn update_user_payment_method(&self, pm: &UserPaymentMethod) -> DbResult<()> { sqlx::query( - "update user_payment_method set card_brand=?,card_last_four=?,exp_month=?,exp_year=?,is_default=?,enabled=? where id=?", + "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)