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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ All notable changes to the LNVPS APIs are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [Unreleased]

### Added

- **2026-07-14** - Unified saved payment methods + Revolut auto-renewal
- Subscriptions with `auto_renewal_enabled` are now renewed automatically by charging the user's **default** saved payment method, dispatched by provider: Nostr Wallet Connect (Lightning) or a saved Revolut card charged off-session (merchant-initiated). Closes #159.
- Saved methods live in a new provider-agnostic `user_payment_method` table (one-to-many per user, with `is_default` and `enabled`). NWC and Revolut are both modelled as payment methods, so users can keep several and choose which is the default. Only opaque provider token references are stored, encrypted at rest, alongside non-sensitive card metadata (brand, last 4, expiry) for display + expiry handling — never card PAN/CVV.
- Revolut cards are saved automatically the next time the user completes a Revolut checkout while auto-renewal is enabled (no separate setup step).
- **New endpoints:**
- `GET /api/v1/payment-methods` — list saved methods (`id`, `provider`, `name`, `card_brand`, `card_last_four`, `exp_month`, `exp_year`, `is_default`, `enabled`, `created`). Tokens/NWC strings are never returned.
- `POST /api/v1/payment-methods` — add a Nostr Wallet Connect connection (`{ nwc_connection_string, name? }`); validated for `pay_invoice` support.
- `PATCH /api/v1/payment-methods/{id}` — set a user-defined `name`, set as default (`is_default`), and/or enable-disable (`enabled`).
- `DELETE /api/v1/payment-methods/{id}` — remove a saved method.
- **Breaking:** the `nwc_connection_string` field on `GET`/`PATCH /api/v1/account` has been removed. Existing NWC connections are migrated into `user_payment_method` (provider `nwc`); manage NWC via the new payment-methods endpoints instead.

## [0.4.0] - 2026-07-13

### Added
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
176 changes: 176 additions & 0 deletions lnvps_api/examples/revolut_sandbox.rs
Original file line number Diff line number Diff line change
@@ -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 <ORDER_ID>
//! -> prints state + CUSTOMER_ID + PAYMENT_METHOD_ID once the checkout completes
//! 3. cargo run --example revolut_sandbox --features revolut -- charge <CUSTOMER_ID> <PAYMENT_METHOD_ID> 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<RevolutApi> {
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<CurrencyAmount> {
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, &currency)?;
// 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(|| "<none>".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(|| "<none>".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=<none> (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, &currency)?;
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(())
}
41 changes: 41 additions & 0 deletions lnvps_api/examples/revolut_widget.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Revolut Save-Card Widget Harness</title>
<script src="https://sandbox-merchant.revolut.com/embed.js"></script>
</head>
<body>
<h1 id="status">loading</h1>
<div id="card-field" style="border:1px solid #ccc;padding:12px;max-width:400px"></div>
<button id="pay">Pay &amp; Save Card</button>
<script>
const params = new URLSearchParams(location.search);
const token = params.get("token");
window.RevolutCheckout(token, "sandbox").then(function (instance) {
const cardField = instance.createCardField({
target: document.getElementById("card-field"),
onSuccess: function () {
document.getElementById("status").innerText = "PAID";
},
onError: function (message) {
document.getElementById("status").innerText = "ERROR: " + message;
},
});
document.getElementById("status").innerText = "ready";
document.getElementById("pay").onclick = function () {
document.getElementById("status").innerText = "submitting";
cardField.submit({
name: "Test User",
email: "mitcard@lnvps.net",
savePaymentMethodFor: "merchant",
billingAddress: {
countryCode: "GB",
postcode: "SW1A1AA",
},
});
};
});
</script>
</body>
</html>
55 changes: 52 additions & 3 deletions lnvps_api/src/api/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,63 @@ pub struct AccountPatchRequest {
deserialize_with = "lnvps_api_common::deserialize_nullable_option"
)]
pub tax_id: Option<Option<String>>,
/// Nostr Wallet Connect connection string for automatic VM renewals
}

/// A saved payment method for automatic renewals. Never exposes the underlying
/// provider tokens / NWC connection string.
#[derive(Serialize, Deserialize)]
pub struct PaymentMethodResponse {
pub id: u64,
/// Payment processor: `nwc` or `revolut`
pub provider: String,
/// Optional user-defined label
pub name: Option<String>,
pub created: DateTime<Utc>,
pub card_brand: Option<String>,
pub card_last_four: Option<String>,
pub exp_month: Option<u16>,
pub exp_year: Option<u16>,
pub is_default: bool,
pub enabled: bool,
}

impl From<lnvps_db::UserPaymentMethod> for PaymentMethodResponse {
fn from(m: lnvps_db::UserPaymentMethod) -> Self {
PaymentMethodResponse {
id: m.id,
provider: m.provider,
name: m.name,
created: m.created,
card_brand: m.card_brand,
card_last_four: m.card_last_four,
exp_month: m.exp_month,
exp_year: m.exp_year,
is_default: m.is_default,
enabled: m.enabled,
}
}
}

/// Add a Nostr Wallet Connect connection as a saved payment method.
#[derive(Deserialize)]
pub struct AddNwcPaymentMethodRequest {
pub nwc_connection_string: String,
/// Optional user-defined label
pub name: Option<String>,
}

/// Update a saved payment method (label / set default / enable-disable).
#[derive(Deserialize)]
pub struct PatchPaymentMethodRequest {
pub is_default: Option<bool>,
pub enabled: Option<bool>,
/// Set/clear the user-defined label. Use `Some(Some(..))` to set, `Some(None)` to clear.
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "lnvps_api_common::deserialize_nullable_option"
)]
pub nwc_connection_string: Option<Option<String>>,
pub name: Option<Option<String>>,
}

impl From<lnvps_db::User> for AccountPatchRequest {
Expand Down Expand Up @@ -172,7 +222,6 @@ impl From<lnvps_db::User> 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())),
}
}
}
Expand Down
25 changes: 14 additions & 11 deletions lnvps_api/src/api/referral.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading