diff --git a/ADMIN_API_ENDPOINTS.md b/ADMIN_API_ENDPOINTS.md index 6ce62970..9ddea6ee 100644 --- a/ADMIN_API_ENDPOINTS.md +++ b/ADMIN_API_ENDPOINTS.md @@ -4787,6 +4787,99 @@ Response: --- +## User Payment Methods Management + +User payment methods are the **saved payment methods that users register on their own account** for automatic +renewals (Nostr Wallet Connect connections, or Revolut cards saved off-session). This is distinct from the +**Payment Method Configuration** above, which stores the provider/merchant settings. + +> **Security:** The underlying provider tokens / NWC connection strings are **never** returned. Responses expose only +> non-sensitive metadata (provider, label, card brand/last4/expiry, default/enabled flags) plus a +> `has_external_customer_id` boolean. + +There is no admin *create* endpoint — methods are added by users via the customer API. Admins can list, inspect, +edit (label / default / enable-disable), and delete them. + +### List User Payment Methods + +``` +GET /api/admin/v1/user_payment_methods +``` + +Query Parameters: + +- `limit`: number (optional) - max 100, default 50 +- `offset`: number (optional) - default 0 +- `user_id`: number (optional) - filter to a single user's saved methods + +Required Permission: `user_payment_method::view` + +Returns a paginated list of `AdminUserPaymentMethodInfo`. + +### Get User Payment Method + +``` +GET /api/admin/v1/user_payment_methods/{id} +``` + +Required Permission: `user_payment_method::view` + +Returns a single `AdminUserPaymentMethodInfo`. + +### Update User Payment Method + +``` +PATCH /api/admin/v1/user_payment_methods/{id} +``` + +Required Permission: `user_payment_method::update` + +Request body (all fields optional): + +```json +{ + "is_default": boolean, + // set this method as the owning user's default (clears the flag on their other methods) + "enabled": boolean, + // enable/disable the method + "name": string | null + // set the user-defined label, or null to clear it +} +``` + +Returns the updated `AdminUserPaymentMethodInfo`. + +### Delete User Payment Method + +``` +DELETE /api/admin/v1/user_payment_methods/{id} +``` + +Required Permission: `user_payment_method::delete` + +### AdminUserPaymentMethodInfo + +```json +{ + "id": number, + "user_id": number, + "provider": "nwc", + // payment processor: "nwc" or "revolut" + "name": "string | null", + "created": "2026-07-15T00:00:00Z", + "has_external_customer_id": boolean, + // whether a provider customer id is on file (the value itself is redacted) + "card_brand": "string | null", + "card_last_four": "string | null", + "exp_month": number | null, + "exp_year": number | null, + "is_default": boolean, + "enabled": boolean +} +``` + +--- + ## Payment Method Configuration Data Types ### AdminPaymentMethodConfigInfo diff --git a/lnvps_api/examples/revolut_sandbox.rs b/lnvps_api/examples/revolut_sandbox.rs index f0b31e46..c18eb227 100644 --- a/lnvps_api/examples/revolut_sandbox.rs +++ b/lnvps_api/examples/revolut_sandbox.rs @@ -112,12 +112,7 @@ async fn main() -> Result<()> { // 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, - ) + .create_subscription("LNVPS auto-renewal sandbox test", amt, Some(email), None) .await?; println!("ORDER_ID={}", info.external_id); println!( @@ -129,7 +124,9 @@ async fn main() -> Result<()> { 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`."); + println!( + "--> Save a card via the widget (savePaymentMethodFor=merchant), then run `status`." + ); } Cmd::Status { order_id } => { let order = api.get_order(&order_id).await?; @@ -147,7 +144,10 @@ async fn main() -> Result<()> { 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); + println!( + "PAYMENT_METHOD_ID={} TYPE={:?} SAVED_FOR={:?}", + m.id, m.kind, m.saved_for + ); } } } diff --git a/lnvps_api/src/api/subscriptions.rs b/lnvps_api/src/api/subscriptions.rs index 49c0fe66..20cb1117 100644 --- a/lnvps_api/src/api/subscriptions.rs +++ b/lnvps_api/src/api/subscriptions.rs @@ -193,7 +193,9 @@ async fn v1_create_subscription( ApiCreateSubscriptionLineItemRequest::AsnSponsoring { asn: _ } => { // TODO: Implement ASN sponsoring pricing lookup // For now, return error - return Err(ApiError::not_implemented("ASN sponsoring not yet implemented")); + return Err(ApiError::not_implemented( + "ASN sponsoring not yet implemented", + )); } ApiCreateSubscriptionLineItemRequest::DnsHosting { domain: _ } => { // TODO: Implement DNS hosting pricing lookup diff --git a/lnvps_api/src/bin/fix_lnurlp_topups.rs b/lnvps_api/src/bin/fix_lnurlp_topups.rs index 89f9867d..57276987 100644 --- a/lnvps_api/src/bin/fix_lnurlp_topups.rs +++ b/lnvps_api/src/bin/fix_lnurlp_topups.rs @@ -327,10 +327,7 @@ async fn plan_and_apply( } let time_value = payment.time_value.unwrap_or(0); - let new_expiry = intended_sub - .expires - .map(|e| e.max(now)) - .unwrap_or(now) + let new_expiry = intended_sub.expires.map(|e| e.max(now)).unwrap_or(now) + chrono::Duration::seconds(time_value as i64); info!( diff --git a/lnvps_api/src/data_migration/dns.rs b/lnvps_api/src/data_migration/dns.rs index 34eaa46f..1ae35c98 100644 --- a/lnvps_api/src/data_migration/dns.rs +++ b/lnvps_api/src/data_migration/dns.rs @@ -1,7 +1,7 @@ use crate::data_migration::DataMigration; -use lnvps_api_common::{BasicRecord, DnsRef, get_dns_server}; use crate::settings::Settings; use anyhow::Result; +use lnvps_api_common::{BasicRecord, DnsRef, get_dns_server}; use lnvps_db::{DnsServer, DnsServerKind, LNVpsDb, RouterKind}; use log::warn; use std::future::Future; diff --git a/lnvps_api/src/host/proxmox.rs b/lnvps_api/src/host/proxmox.rs index b8e34d3e..a188030e 100644 --- a/lnvps_api/src/host/proxmox.rs +++ b/lnvps_api/src/host/proxmox.rs @@ -2,13 +2,13 @@ use crate::host::{ FullVmInfo, TerminalStream, TimeSeries, TimeSeriesData, VmHostClient, VmHostDiskInfo, VmHostInfo, }; -use lnvps_api_common::JsonApi; use crate::settings::{QemuConfig, SshConfig}; use crate::ssh_client::SshClient; use anyhow::Result; use async_trait::async_trait; use chrono::Utc; use ipnetwork::IpNetwork; +use lnvps_api_common::JsonApi; use lnvps_api_common::retry::{OpError, OpResult, Pipeline, RetryPolicy}; use lnvps_api_common::{VmRunningState, VmRunningStates, op_fatal, parse_gateway}; use lnvps_db::{DiskType, IpRangeAllocationMode, Vm, VmOsImage}; diff --git a/lnvps_api/src/notifications/nip17.rs b/lnvps_api/src/notifications/nip17.rs index d9bf1736..f3fe2373 100644 --- a/lnvps_api/src/notifications/nip17.rs +++ b/lnvps_api/src/notifications/nip17.rs @@ -37,8 +37,7 @@ impl NotificationChannel for Nip17Channel { .signer() .await .map_err(|e| OpError::Transient(e.into()))?; - let pubkey = - PublicKey::from_slice(&user.pubkey).map_err(|e| OpError::Fatal(e.into()))?; + let pubkey = PublicKey::from_slice(&user.pubkey).map_err(|e| OpError::Fatal(e.into()))?; let ev = EventBuilder::private_msg(&sig, pubkey, notification.message.clone(), None) .await .map_err(|e| OpError::Transient(e.into()))?; diff --git a/lnvps_api/src/notifications/telegram.rs b/lnvps_api/src/notifications/telegram.rs index e48a1bb3..db4431e9 100644 --- a/lnvps_api/src/notifications/telegram.rs +++ b/lnvps_api/src/notifications/telegram.rs @@ -177,9 +177,11 @@ impl NotificationChannel for TelegramChannel { }; match self.client.send_message(chat_id, &text).await { Ok(()) => Ok(()), - Err(e @ TelegramError::Api { permanent: true, .. }) => { - Err(OpError::Fatal(anyhow::Error::msg(e.to_string()))) - } + Err( + e @ TelegramError::Api { + permanent: true, .. + }, + ) => Err(OpError::Fatal(anyhow::Error::msg(e.to_string()))), Err(e) => Err(OpError::Transient(anyhow::Error::msg(e.to_string()))), } } diff --git a/lnvps_api/src/notifications/whatsapp.rs b/lnvps_api/src/notifications/whatsapp.rs index 794812d6..18626cf9 100644 --- a/lnvps_api/src/notifications/whatsapp.rs +++ b/lnvps_api/src/notifications/whatsapp.rs @@ -185,9 +185,11 @@ impl NotificationChannel for WhatsAppChannel { .await { Ok(()) => Ok(()), - Err(e @ WhatsAppError::Api { permanent: true, .. }) => { - Err(OpError::Fatal(anyhow::Error::msg(e.to_string()))) - } + Err( + e @ WhatsAppError::Api { + permanent: true, .. + }, + ) => Err(OpError::Fatal(anyhow::Error::msg(e.to_string()))), Err(e) => Err(OpError::Transient(anyhow::Error::msg(e.to_string()))), } } diff --git a/lnvps_api/src/payments/revolut.rs b/lnvps_api/src/payments/revolut.rs index 641d0bed..54af9442 100644 --- a/lnvps_api/src/payments/revolut.rs +++ b/lnvps_api/src/payments/revolut.rs @@ -255,7 +255,10 @@ impl RevolutPaymentHandler { // 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 { + 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 diff --git a/lnvps_api/src/provisioner/retry_tests.rs b/lnvps_api/src/provisioner/retry_tests.rs index b22c4d5a..ba37d634 100644 --- a/lnvps_api/src/provisioner/retry_tests.rs +++ b/lnvps_api/src/provisioner/retry_tests.rs @@ -5,13 +5,13 @@ #[cfg(test)] mod tests { - use lnvps_api_common::{BasicRecord, DnsRef, DnsServer, RecordType}; use crate::mocks::{MockDnsServer, MockNode, MockRouter}; use crate::router::{ArpEntry, Router}; use crate::settings::mock_settings; use anyhow::{Result, anyhow}; use async_trait::async_trait; use lnvps_api_common::retry::{OpError, OpResult}; + use lnvps_api_common::{BasicRecord, DnsRef, DnsServer, RecordType}; use lnvps_api_common::{InMemoryRateCache, MockDb}; use lnvps_db::{LNVpsDbBase, User, UserSshKey}; use std::sync::Arc; diff --git a/lnvps_api/src/provisioner/vm_network.rs b/lnvps_api/src/provisioner/vm_network.rs index 25fad276..6cf04fcd 100644 --- a/lnvps_api/src/provisioner/vm_network.rs +++ b/lnvps_api/src/provisioner/vm_network.rs @@ -1,9 +1,9 @@ -use lnvps_api_common::{BasicRecord, DnsRef, get_dns_server}; use crate::router::{ArpEntry, get_router}; use anyhow::{Context, anyhow}; use ipnetwork::IpNetwork; use lnvps_api_common::op_fatal; use lnvps_api_common::retry::OpResult; +use lnvps_api_common::{BasicRecord, DnsRef, get_dns_server}; use lnvps_db::{AccessPolicy, IpRange, LNVpsDb, NetworkAccessPolicy, VmIpAssignment}; use log::warn; use std::net::IpAddr; diff --git a/lnvps_api/src/router/mikrotik.rs b/lnvps_api/src/router/mikrotik.rs index 6539a3ea..678c31db 100644 --- a/lnvps_api/src/router/mikrotik.rs +++ b/lnvps_api/src/router/mikrotik.rs @@ -1,4 +1,3 @@ -use lnvps_api_common::JsonApi; use crate::router::{ ArpEntry, BgpPeer, BgpPeerDirection, BgpRoute, BgpRouter, BgpSession, GreConfig, Router, Tunnel, TunnelConfig, TunnelKind, TunnelRouter, TunnelTraffic, VxlanConfig, WireguardConfig, @@ -8,6 +7,7 @@ use anyhow::{Context, Result}; use async_trait::async_trait; use base64::Engine; use base64::engine::general_purpose::STANDARD; +use lnvps_api_common::JsonApi; use lnvps_api_common::op_fatal; use lnvps_api_common::retry::{OpError, OpResult}; use reqwest::Method; diff --git a/lnvps_api/src/router/ovh.rs b/lnvps_api/src/router/ovh.rs index bb784cc5..a449359f 100644 --- a/lnvps_api/src/router/ovh.rs +++ b/lnvps_api/src/router/ovh.rs @@ -1,10 +1,10 @@ -use lnvps_api_common::JsonApi; -use lnvps_api_common::ovh_json_api; use crate::router::{ArpEntry, Router}; use anyhow::Result; use async_trait::async_trait; use chrono::{DateTime, Utc}; +use lnvps_api_common::JsonApi; use lnvps_api_common::op_transient; +use lnvps_api_common::ovh_json_api; use lnvps_api_common::retry::{OpError, OpResult}; use log::{info, warn}; use reqwest::Method; diff --git a/lnvps_api/src/subscription/vm.rs b/lnvps_api/src/subscription/vm.rs index 2c9d9ea7..e1f1c6a5 100644 --- a/lnvps_api/src/subscription/vm.rs +++ b/lnvps_api/src/subscription/vm.rs @@ -8,8 +8,8 @@ use lnvps_api_common::{ WorkJob, }; use lnvps_db::{ - LNVpsDb, Subscription, SubscriptionLineItem, SubscriptionPayment, - SubscriptionPaymentType, SubscriptionType, Vm, + LNVpsDb, Subscription, SubscriptionLineItem, SubscriptionPayment, SubscriptionPaymentType, + SubscriptionType, Vm, }; use log::{error, info, warn}; use std::sync::Arc; @@ -199,16 +199,16 @@ impl SubscriptionLineItemHandler for VmLineItemHandler { Ok(()) } - async fn on_expired( - &self, - sub: &Subscription, - line_item: &SubscriptionLineItem, - ) -> Result<()> { + async fn on_expired(&self, sub: &Subscription, line_item: &SubscriptionLineItem) -> Result<()> { // skip anything that isn't the vm line item (skip upgrade lines) if line_item.subscription_type != SubscriptionType::Vps { return Ok(()); } - let grace_days = crate::worker::grace_period_days_for_sub(sub, Utc::now(), self.provisioner.delete_after); + let grace_days = crate::worker::grace_period_days_for_sub( + sub, + Utc::now(), + self.provisioner.delete_after, + ); info!("Stopping expired VM {}", self.vm.id); // Stop is best-effort (the host may be unreachable or the VM already // stopped), but the history entry must always be written: it is the diff --git a/lnvps_api_admin/src/admin/cost_plans.rs b/lnvps_api_admin/src/admin/cost_plans.rs index 461d6e16..1dd83cff 100644 --- a/lnvps_api_admin/src/admin/cost_plans.rs +++ b/lnvps_api_admin/src/admin/cost_plans.rs @@ -6,7 +6,9 @@ use crate::admin::model::{ use axum::extract::{Path, Query, State}; use axum::routing::get; use axum::{Json, Router}; -use lnvps_api_common::{ApiData, ApiError, ApiPaginatedData, ApiPaginatedResult, ApiResult, PageQuery}; +use lnvps_api_common::{ + ApiData, ApiError, ApiPaginatedData, ApiPaginatedResult, ApiResult, PageQuery, +}; use lnvps_db::{AdminAction, AdminResource, LNVpsDb, VmCostPlan}; use std::sync::Arc; diff --git a/lnvps_api_admin/src/admin/hosts.rs b/lnvps_api_admin/src/admin/hosts.rs index 6c6d9847..740a1a0b 100644 --- a/lnvps_api_admin/src/admin/hosts.rs +++ b/lnvps_api_admin/src/admin/hosts.rs @@ -5,8 +5,8 @@ use axum::extract::{Path, Query, State}; use axum::routing::get; use axum::{Json, Router}; use lnvps_api_common::{ - ApiData, ApiDiskInterface, ApiDiskType, ApiError, ApiPaginatedData, ApiPaginatedResult, ApiResult, - PageQuery, + ApiData, ApiDiskInterface, ApiDiskType, ApiError, ApiPaginatedData, ApiPaginatedResult, + ApiResult, PageQuery, }; use lnvps_db::{AdminAction, AdminResource}; use serde::Deserialize; @@ -353,7 +353,10 @@ async fn admin_get_host_disk( // Verify disk belongs to this host if disk.host_id != host_id { - return Err(ApiError::not_found(format!("Disk {} does not belong to host {}", disk_id, host_id))); + return Err(ApiError::not_found(format!( + "Disk {} does not belong to host {}", + disk_id, host_id + ))); } ApiData::ok(disk.into()) @@ -377,7 +380,10 @@ async fn admin_update_host_disk( // Verify disk belongs to this host if disk.host_id != host_id { - return Err(ApiError::not_found(format!("Disk {} does not belong to host {}", disk_id, host_id))); + return Err(ApiError::not_found(format!( + "Disk {} does not belong to host {}", + disk_id, host_id + ))); } // Update fields if provided diff --git a/lnvps_api_admin/src/admin/ip_space.rs b/lnvps_api_admin/src/admin/ip_space.rs index a05d5b0f..064dd85a 100644 --- a/lnvps_api_admin/src/admin/ip_space.rs +++ b/lnvps_api_admin/src/admin/ip_space.rs @@ -344,7 +344,9 @@ async fn admin_get_ip_space_pricing( // Verify the pricing belongs to this space if pricing.available_ip_space_id != space_id { - return Err(ApiError::not_found("Pricing does not belong to the specified IP space")); + return Err(ApiError::not_found( + "Pricing does not belong to the specified IP space", + )); } let mut info = AdminIpSpacePricingInfo::from(pricing); @@ -418,7 +420,9 @@ async fn admin_update_ip_space_pricing( // Verify the pricing belongs to this space if pricing.available_ip_space_id != space_id { - return Err(ApiError::not_found("Pricing does not belong to the specified IP space")); + return Err(ApiError::not_found( + "Pricing does not belong to the specified IP space", + )); } // Update fields if provided diff --git a/lnvps_api_admin/src/admin/mod.rs b/lnvps_api_admin/src/admin/mod.rs index fd446be4..4b049c55 100644 --- a/lnvps_api_admin/src/admin/mod.rs +++ b/lnvps_api_admin/src/admin/mod.rs @@ -22,6 +22,7 @@ mod reports; mod roles; mod routers; mod subscriptions; +mod user_payment_methods; mod users; mod vm_ip_assignments; mod vm_os_images; @@ -68,6 +69,7 @@ pub fn admin_router( .merge(reports::router()) .merge(websocket::router()) .merge(payment_methods::router()) + .merge(user_payment_methods::router()) .with_state(RouterState { db, work_commander, diff --git a/lnvps_api_admin/src/admin/model.rs b/lnvps_api_admin/src/admin/model.rs index c186040e..a164bb53 100644 --- a/lnvps_api_admin/src/admin/model.rs +++ b/lnvps_api_admin/src/admin/model.rs @@ -3582,6 +3582,63 @@ impl PartialProviderConfig { } } +/// Admin view of a user's saved payment method for automatic renewals. +/// Never exposes the underlying provider tokens / NWC connection string. +#[derive(Serialize, Deserialize)] +pub struct AdminUserPaymentMethodInfo { + pub id: u64, + /// User that owns this payment method + pub user_id: u64, + /// Payment processor: `nwc` or `revolut` + pub provider: String, + /// Optional user-defined label + pub name: Option, + pub created: DateTime, + /// Whether a provider customer id is on file (secret value is redacted) + pub has_external_customer_id: bool, + 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 AdminUserPaymentMethodInfo { + fn from(m: lnvps_db::UserPaymentMethod) -> Self { + Self { + id: m.id, + user_id: m.user_id, + provider: m.provider, + name: m.name, + created: m.created, + has_external_customer_id: m.external_customer_id.is_some(), + 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, + } + } +} + +/// Admin request to update a user's saved payment method. +/// Only mutates non-sensitive fields (label / default / enabled). +#[derive(Deserialize)] +pub struct AdminUpdateUserPaymentMethodRequest { + /// Mark this method as the user's default (clears the flag on their others) + pub is_default: Option, + /// Enable/disable the method + pub enabled: Option, + /// Set/clear the user-defined label. `Some(Some(..))` sets, `Some(None)` clears. + #[serde( + default, + deserialize_with = "lnvps_api_common::deserialize_nullable_option" + )] + pub name: Option>, +} + #[cfg(test)] mod tests { use super::*; diff --git a/lnvps_api_admin/src/admin/payment_methods.rs b/lnvps_api_admin/src/admin/payment_methods.rs index 9e6d65b6..b5285930 100644 --- a/lnvps_api_admin/src/admin/payment_methods.rs +++ b/lnvps_api_admin/src/admin/payment_methods.rs @@ -7,7 +7,9 @@ use crate::admin::model::{ use axum::extract::{Path, Query, State}; use axum::routing::get; use axum::{Json, Router}; -use lnvps_api_common::{ApiData, ApiError, ApiPaginatedData, ApiPaginatedResult, ApiResult, PageQuery}; +use lnvps_api_common::{ + ApiData, ApiError, ApiPaginatedData, ApiPaginatedResult, ApiResult, PageQuery, +}; use lnvps_db::{AdminAction, AdminResource}; pub fn router() -> Router { @@ -92,7 +94,9 @@ async fn admin_update_payment_method( // Update fields if provided if let Some(name) = &request.name { if name.trim().is_empty() { - return Err(ApiError::bad_request("Payment method config name cannot be empty")); + return Err(ApiError::bad_request( + "Payment method config name cannot be empty", + )); } config.name = name.trim().to_string(); } diff --git a/lnvps_api_admin/src/admin/roles.rs b/lnvps_api_admin/src/admin/roles.rs index 6c289460..6b9666b9 100644 --- a/lnvps_api_admin/src/admin/roles.rs +++ b/lnvps_api_admin/src/admin/roles.rs @@ -7,7 +7,9 @@ use crate::admin::model::{ use axum::extract::{Path, Query, State}; use axum::routing::{delete, get}; use axum::{Json, Router}; -use lnvps_api_common::{ApiData, ApiError, ApiPaginatedData, ApiPaginatedResult, ApiResult, PageQuery}; +use lnvps_api_common::{ + ApiData, ApiError, ApiPaginatedData, ApiPaginatedResult, ApiResult, PageQuery, +}; use lnvps_db::{AdminAction, AdminResource}; pub fn router() -> Router { @@ -355,7 +357,9 @@ async fn admin_revoke_user_role( for user_role_id in user_roles { let user_role = this.db.get_role(user_role_id).await?; if user_role.name == "super_admin" { - return Err(ApiError::forbidden("Super admins cannot revoke their own super_admin role")); + return Err(ApiError::forbidden( + "Super admins cannot revoke their own super_admin role", + )); } } } diff --git a/lnvps_api_admin/src/admin/user_payment_methods.rs b/lnvps_api_admin/src/admin/user_payment_methods.rs new file mode 100644 index 00000000..2b194580 --- /dev/null +++ b/lnvps_api_admin/src/admin/user_payment_methods.rs @@ -0,0 +1,129 @@ +use crate::admin::RouterState; +use crate::admin::auth::AdminAuth; +use crate::admin::model::{AdminUpdateUserPaymentMethodRequest, AdminUserPaymentMethodInfo}; +use axum::extract::{Path, Query, State}; +use axum::routing::get; +use axum::{Json, Router}; +use lnvps_api_common::{ + ApiData, ApiPaginatedData, ApiPaginatedResult, ApiResult, deserialize_from_str_optional, +}; +use lnvps_db::{AdminAction, AdminResource}; +use serde::Deserialize; + +pub fn router() -> Router { + Router::new() + .route( + "/api/admin/v1/user_payment_methods", + get(admin_list_user_payment_methods), + ) + .route( + "/api/admin/v1/user_payment_methods/{id}", + get(admin_get_user_payment_method) + .patch(admin_update_user_payment_method) + .delete(admin_delete_user_payment_method), + ) +} + +#[derive(Deserialize, Default)] +#[serde(default)] +struct ListUserPaymentMethodsQuery { + #[serde(deserialize_with = "deserialize_from_str_optional")] + limit: Option, + #[serde(deserialize_with = "deserialize_from_str_optional")] + offset: Option, + /// Optional filter to a single user's payment methods + #[serde(deserialize_with = "deserialize_from_str_optional")] + user_id: Option, +} + +/// List saved payment methods across all users (optionally filter by `user_id`) +async fn admin_list_user_payment_methods( + auth: AdminAuth, + State(this): State, + Query(params): Query, +) -> ApiPaginatedResult { + auth.require_permission(AdminResource::UserPaymentMethod, AdminAction::View)?; + + let limit = params.limit.unwrap_or(50).min(100); + let offset = params.offset.unwrap_or(0); + + let (page, total) = this + .db + .admin_list_user_payment_methods_paginated(limit, offset, params.user_id) + .await?; + + let methods: Vec = page + .into_iter() + .map(AdminUserPaymentMethodInfo::from) + .collect(); + + ApiPaginatedData::ok(methods, total, limit, offset) +} + +/// Get a specific user payment method +async fn admin_get_user_payment_method( + auth: AdminAuth, + State(this): State, + Path(id): Path, +) -> ApiResult { + auth.require_permission(AdminResource::UserPaymentMethod, AdminAction::View)?; + + let method = this.db.get_user_payment_method(id).await?; + ApiData::ok(AdminUserPaymentMethodInfo::from(method)) +} + +/// Update a user payment method (label / set default / enable-disable) +async fn admin_update_user_payment_method( + auth: AdminAuth, + State(this): State, + Path(id): Path, + Json(request): Json, +) -> ApiResult { + auth.require_permission(AdminResource::UserPaymentMethod, AdminAction::Update)?; + + let mut method = this.db.get_user_payment_method(id).await?; + + if let Some(enabled) = request.enabled { + method.enabled = enabled; + } + if let Some(name) = &request.name { + method.name = name + .clone() + .map(|n| n.trim().to_string()) + .filter(|n| !n.is_empty()); + } + if request.is_default == Some(true) { + // Only one default per user: clear the flag on the owner's other methods. + for mut other in this + .db + .list_user_payment_methods(method.user_id, 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 request.is_default == Some(false) { + method.is_default = false; + } + + this.db.update_user_payment_method(&method).await?; + let updated = this.db.get_user_payment_method(id).await?; + ApiData::ok(AdminUserPaymentMethodInfo::from(updated)) +} + +/// Delete a user payment method +async fn admin_delete_user_payment_method( + auth: AdminAuth, + State(this): State, + Path(id): Path, +) -> ApiResult<()> { + auth.require_permission(AdminResource::UserPaymentMethod, AdminAction::Delete)?; + + // Verify it exists first for a clean 404 rather than silent success. + let _ = this.db.get_user_payment_method(id).await?; + this.db.delete_user_payment_method(id).await?; + ApiData::ok(()) +} diff --git a/lnvps_api_admin/src/admin/users.rs b/lnvps_api_admin/src/admin/users.rs index 3e294d70..14f03507 100644 --- a/lnvps_api_admin/src/admin/users.rs +++ b/lnvps_api_admin/src/admin/users.rs @@ -5,7 +5,9 @@ use axum::extract::{Path, Query, State}; use axum::routing::get; use axum::{Json, Router}; use isocountry::CountryCode; -use lnvps_api_common::{ApiData, ApiError, ApiPaginatedData, ApiPaginatedResult, ApiResult, PageQuery}; +use lnvps_api_common::{ + ApiData, ApiError, ApiPaginatedData, ApiPaginatedResult, ApiResult, PageQuery, +}; use lnvps_db::{AdminAction, AdminResource, email_hash}; use serde::Deserialize; diff --git a/lnvps_api_admin/src/admin/vm_ip_assignments.rs b/lnvps_api_admin/src/admin/vm_ip_assignments.rs index 43e829f6..59a8bda9 100644 --- a/lnvps_api_admin/src/admin/vm_ip_assignments.rs +++ b/lnvps_api_admin/src/admin/vm_ip_assignments.rs @@ -127,7 +127,9 @@ async fn admin_create_vm_ip_assignment( // Validate IP range exists and is enabled let ip_range = this.db.admin_get_ip_range(req.ip_range_id).await?; if !ip_range.enabled { - return Err(ApiError::conflict("Cannot assign IP from a disabled IP range")); + return Err(ApiError::conflict( + "Cannot assign IP from a disabled IP range", + )); } // If IP is provided, validate it's within the range diff --git a/lnvps_api_admin/src/admin/vm_templates.rs b/lnvps_api_admin/src/admin/vm_templates.rs index 38716330..f82f814c 100644 --- a/lnvps_api_admin/src/admin/vm_templates.rs +++ b/lnvps_api_admin/src/admin/vm_templates.rs @@ -7,7 +7,9 @@ use axum::extract::{Path, Query, State}; use axum::routing::get; use axum::{Json, Router}; use chrono::Utc; -use lnvps_api_common::{ApiData, ApiError, ApiPaginatedData, ApiPaginatedResult, ApiResult, PageQuery}; +use lnvps_api_common::{ + ApiData, ApiError, ApiPaginatedData, ApiPaginatedResult, ApiResult, PageQuery, +}; use lnvps_db::{AdminAction, AdminResource, LNVpsDb, VmTemplate}; use std::sync::Arc; @@ -155,7 +157,9 @@ async fn admin_create_vm_template( .unwrap_or(lnvps_api_common::ApiIntervalType::Month); if cost_plan_interval_amount == 0 { - return Err(ApiError::bad_request("Cost plan interval amount cannot be zero")); + return Err(ApiError::bad_request( + "Cost plan interval amount cannot be zero", + )); } let new_cost_plan = lnvps_db::VmCostPlan { @@ -297,7 +301,9 @@ async fn admin_update_vm_template( } if let Some(cost_plan_interval_amount) = req.cost_plan_interval_amount { if cost_plan_interval_amount == 0 { - return Err(ApiError::bad_request("Cost plan interval amount cannot be zero")); + return Err(ApiError::bad_request( + "Cost plan interval amount cannot be zero", + )); } cost_plan.interval_amount = cost_plan_interval_amount; } diff --git a/lnvps_api_common/src/capacity.rs b/lnvps_api_common/src/capacity.rs index 48e61ed3..df6be78d 100644 --- a/lnvps_api_common/src/capacity.rs +++ b/lnvps_api_common/src/capacity.rs @@ -176,9 +176,7 @@ impl HostCapacityService { let max_disk_size = hosts_in_region .clone() .filter(host_has_ipv4) - .filter(|h| { - h.available_cpu() >= min_cpu && h.available_memory() >= min_memory - }) + .filter(|h| h.available_cpu() >= min_cpu && h.available_memory() >= min_memory) .flat_map(|h| { h.disks .iter() diff --git a/lnvps_api_common/src/json_api.rs b/lnvps_api_common/src/json_api.rs index dae93405..cba83846 100644 --- a/lnvps_api_common/src/json_api.rs +++ b/lnvps_api_common/src/json_api.rs @@ -1,6 +1,6 @@ -use anyhow::{Result, anyhow}; use crate::retry::{OpError, OpResult}; use crate::{op_fatal, op_transient}; +use anyhow::{Result, anyhow}; use log::debug; use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, USER_AGENT}; use reqwest::{Client, Method, Request, RequestBuilder, Url}; diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index bfc9a8af..2d16506e 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -10,9 +10,8 @@ use lnvps_db::{ ReferralPayout, Router, RouterBgpRoute, RouterBgpSession, RouterTunnel, RouterTunnelTraffic, Subscription, SubscriptionLineItem, SubscriptionPayment, SubscriptionPaymentWithCompany, User, UserPaymentMethod, UserSshKey, Vm, VmCostPlan, VmCustomPricing, VmCustomPricingDisk, - VmCustomTemplate, - VmFirewallPolicy, VmFirewallRule, VmHistory, VmHost, VmHostDisk, VmHostKind, VmHostRegion, - VmIpAssignment, VmOsImage, VmPayment, VmTemplate, + VmCustomTemplate, VmFirewallPolicy, VmFirewallRule, VmHistory, VmHost, VmHostDisk, VmHostKind, + VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate, }; use async_trait::async_trait; @@ -389,6 +388,21 @@ impl LNVpsDbBase for MockDb { Ok(()) } + async fn set_user_geo( + &self, + user_id: u64, + country_code: Option<&str>, + ip: &str, + ) -> DbResult<()> { + let mut users = self.users.lock().await; + if let Some(u) = users.get_mut(&user_id) { + u.geo_country_code = country_code.map(|s| s.to_string()); + u.geo_ip = Some(ip.to_string()); + u.geo_updated = Some(chrono::Utc::now()); + } + Ok(()) + } + async fn delete_user(&self, id: u64) -> DbResult<()> { let mut users = self.users.lock().await; users.remove(&id); @@ -481,6 +495,28 @@ impl LNVpsDbBase for MockDb { .ok_or_else(|| DbError::from(anyhow!("Payment method not found"))) } + async fn admin_list_user_payment_methods_paginated( + &self, + limit: u64, + offset: u64, + user_id: Option, + ) -> DbResult<(Vec, u64)> { + let methods = self.user_payment_methods.lock().await; + let mut all: Vec = methods + .values() + .filter(|m| user_id.map(|u| m.user_id == u).unwrap_or(true)) + .cloned() + .collect(); + all.sort_by(|a, b| b.id.cmp(&a.id)); + let total = all.len() as u64; + let page = all + .into_iter() + .skip(offset as usize) + .take(limit as usize) + .collect(); + Ok((page, total)) + } + 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()); @@ -3470,13 +3506,25 @@ mod tests { }; // 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(); + 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(); + 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"); @@ -3484,6 +3532,35 @@ mod tests { let all = db.list_user_payment_methods(1, None).await.unwrap(); assert_eq!(all.len(), 3); + // Admin cross-user paginated listing + user filter + let _other = db + .insert_user_payment_method(&mk(2, "nwc", true)) + .await + .unwrap(); + let (page, total) = db + .admin_list_user_payment_methods_paginated(10, 0, None) + .await + .unwrap(); + assert_eq!(total, 4); + assert_eq!(page.len(), 4); + // Newest id first + assert!(page[0].id > page[1].id); + // Pagination: limit 2 returns 2 of 4 + let (page2, total2) = db + .admin_list_user_payment_methods_paginated(2, 0, None) + .await + .unwrap(); + assert_eq!(total2, 4); + assert_eq!(page2.len(), 2); + // Filter to user 2 + let (u2, u2_total) = db + .admin_list_user_payment_methods_paginated(10, 0, Some(2)) + .await + .unwrap(); + assert_eq!(u2_total, 1); + assert_eq!(u2.len(), 1); + assert_eq!(u2[0].user_id, 2); + // Get one let got = db.get_user_payment_method(id1).await.unwrap(); assert_eq!(got.provider, "revolut"); @@ -3500,7 +3577,13 @@ mod tests { // 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); + 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). diff --git a/lnvps_db/migrations/20260715124301_user_payment_method_rbac_permissions.sql b/lnvps_db/migrations/20260715124301_user_payment_method_rbac_permissions.sql new file mode 100644 index 00000000..112e9f83 --- /dev/null +++ b/lnvps_db/migrations/20260715124301_user_payment_method_rbac_permissions.sql @@ -0,0 +1,19 @@ +-- RBAC permissions for user payment method management +-- +-- Adds the UserPaymentMethod admin resource (AdminResource::UserPaymentMethod = 23) +-- which the admin endpoints in `lnvps_api_admin/src/admin/user_payment_methods.rs` +-- gate on (list / get / update / delete of users' saved payment methods). +-- This grants the full set so super_admin can manage them. +-- +-- AdminAction: Create = 0, View = 1, Update = 2, Delete = 3 +-- Note: there is no admin Create endpoint (methods are added by users), but we +-- grant the full set for consistency with the other resources. + +INSERT IGNORE INTO admin_role_permissions (role_id, resource, action, created_at) +SELECT id, 23, 0, NOW() FROM admin_roles WHERE name = 'super_admin' +UNION ALL +SELECT id, 23, 1, NOW() FROM admin_roles WHERE name = 'super_admin' +UNION ALL +SELECT id, 23, 2, NOW() FROM admin_roles WHERE name = 'super_admin' +UNION ALL +SELECT id, 23, 3, NOW() FROM admin_roles WHERE name = 'super_admin'; diff --git a/lnvps_db/src/lib.rs b/lnvps_db/src/lib.rs index 1cea5442..c698e61d 100644 --- a/lnvps_db/src/lib.rs +++ b/lnvps_db/src/lib.rs @@ -109,6 +109,18 @@ pub trait LNVpsDbBase: Send + Sync { /// Update user record async fn update_user(&self, user: &User) -> DbResult<()>; + /// Store IP-derived geolocation evidence for a user (place-of-supply / VAT). + /// + /// Written independently of [`update_user`] so background geolocation does + /// not race with user-initiated profile edits. `country_code` is ISO 3166-1 + /// alpha-3 (or `None` if the IP could not be resolved to a country). + async fn set_user_geo( + &self, + user_id: u64, + country_code: Option<&str>, + ip: &str, + ) -> DbResult<()>; + /// Delete user record async fn delete_user(&self, id: u64) -> DbResult<()>; @@ -148,6 +160,15 @@ pub trait LNVpsDbBase: Send + Sync { /// Get a single saved payment method by id async fn get_user_payment_method(&self, id: u64) -> DbResult; + /// Admin: list saved payment methods across all users (optionally filtered + /// to a single user), paginated. Returns the page plus the total count. + async fn admin_list_user_payment_methods_paginated( + &self, + limit: u64, + offset: u64, + user_id: Option, + ) -> DbResult<(Vec, u64)>; + /// Update a saved payment method (enabled/default/card metadata) async fn update_user_payment_method(&self, pm: &UserPaymentMethod) -> DbResult<()>; diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index 7526a229..161ac949 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -62,6 +62,19 @@ pub struct User { pub billing_postcode: Option, /// Billing tax id pub billing_tax_id: Option, + /// Country (ISO 3166-1 alpha-3) resolved from the client's IP address. + /// + /// This is an *independent* place-of-supply evidence signal for EU VAT, + /// captured automatically and stored separately from the self-declared + /// `country_code` so the two can be compared / conflicts flagged. + #[sqlx(default)] + pub geo_country_code: Option, + /// Last client IP address geolocation was resolved from. + #[sqlx(default)] + pub geo_ip: Option, + /// When the geolocation was last resolved. + #[sqlx(default)] + pub geo_updated: Option>, } /// A saved payment method for off-session (merchant-initiated) automatic @@ -1582,6 +1595,7 @@ pub enum AdminResource { IpSpace = 20, PaymentMethodConfig = 21, DnsServer = 22, + UserPaymentMethod = 23, } /// Actions that can be performed on administrative resources @@ -1620,6 +1634,7 @@ impl Display for AdminResource { AdminResource::IpSpace => write!(f, "ip_space"), AdminResource::PaymentMethodConfig => write!(f, "payment_method_config"), AdminResource::DnsServer => write!(f, "dns_server"), + AdminResource::UserPaymentMethod => write!(f, "user_payment_method"), } } } @@ -1652,6 +1667,7 @@ impl FromStr for AdminResource { "ip_space" => Ok(AdminResource::IpSpace), "payment_method_config" => Ok(AdminResource::PaymentMethodConfig), "dns_server" => Ok(AdminResource::DnsServer), + "user_payment_method" => Ok(AdminResource::UserPaymentMethod), _ => Err(anyhow!("unknown admin resource: {}", s)), } } @@ -1685,6 +1701,7 @@ impl TryFrom for AdminResource { 20 => Ok(AdminResource::IpSpace), 21 => Ok(AdminResource::PaymentMethodConfig), 22 => Ok(AdminResource::DnsServer), + 23 => Ok(AdminResource::UserPaymentMethod), _ => Err(anyhow!("unknown admin resource value: {}", value)), } } @@ -1717,6 +1734,7 @@ impl AdminResource { AdminResource::IpSpace, AdminResource::PaymentMethodConfig, AdminResource::DnsServer, + AdminResource::UserPaymentMethod, ] } } @@ -2039,6 +2057,23 @@ mod tests { assert!(AdminResource::all().contains(&AdminResource::DnsServer)); } + #[test] + fn test_admin_resource_user_payment_method_roundtrip() { + assert_eq!( + "user_payment_method".parse::().unwrap(), + AdminResource::UserPaymentMethod + ); + assert_eq!( + AdminResource::UserPaymentMethod.to_string(), + "user_payment_method" + ); + assert_eq!( + AdminResource::try_from(23u16).unwrap(), + AdminResource::UserPaymentMethod + ); + assert!(AdminResource::all().contains(&AdminResource::UserPaymentMethod)); + } + #[test] fn test_cpu_mfg_from_str() { assert_eq!("intel".parse::().unwrap(), CpuMfg::Intel); diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index f480a960..d9077293 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -4,10 +4,9 @@ use crate::{ PaymentType, Referral, ReferralCostUsage, ReferralPayout, RegionStats, Router, RouterBgpRoute, RouterBgpSession, RouterTunnel, RouterTunnelTraffic, Subscription, SubscriptionLineItem, SubscriptionPayment, SubscriptionPaymentWithCompany, User, UserPaymentMethod, UserSshKey, Vm, - VmCostPlan, - VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmFirewallPolicy, VmFirewallRule, - VmForMigration, VmHistory, VmHost, VmHostDisk, VmHostRegion, VmIpAssignment, VmOsImage, - VmPayment, VmPaymentRaw, VmTemplate, + VmCostPlan, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmFirewallPolicy, + VmFirewallRule, VmForMigration, VmHistory, VmHost, VmHostDisk, VmHostRegion, VmIpAssignment, + VmOsImage, VmPayment, VmPaymentRaw, VmTemplate, }; #[cfg(feature = "admin")] use crate::{AdminDb, AdminRole, AdminRoleAssignment, AdminVmHost}; @@ -246,6 +245,23 @@ impl LNVpsDbBase for LNVpsDbMysql { Ok(()) } + async fn set_user_geo( + &self, + user_id: u64, + country_code: Option<&str>, + ip: &str, + ) -> DbResult<()> { + sqlx::query( + "update users set geo_country_code=?, geo_ip=?, geo_updated=current_timestamp where id=?", + ) + .bind(country_code) + .bind(ip) + .bind(user_id) + .execute(&self.db) + .await?; + Ok(()) + } + async fn delete_user(&self, _id: u64) -> DbResult<()> { Err(DbError::Source( anyhow!("Deleting users is not supported").into_boxed_dyn_error(), @@ -361,10 +377,48 @@ impl LNVpsDbBase for LNVpsDbMysql { } 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?) + Ok( + sqlx::query_as("select * from user_payment_method where id=?") + .bind(id) + .fetch_one(&self.db) + .await?, + ) + } + + async fn admin_list_user_payment_methods_paginated( + &self, + limit: u64, + offset: u64, + user_id: Option, + ) -> DbResult<(Vec, u64)> { + if let Some(user_id) = user_id { + let total: i64 = + sqlx::query_scalar("select count(*) from user_payment_method where user_id=?") + .bind(user_id) + .fetch_one(&self.db) + .await?; + let rows = sqlx::query_as( + "select * from user_payment_method where user_id=? order by id desc limit ? offset ?", + ) + .bind(user_id) + .bind(limit) + .bind(offset) + .fetch_all(&self.db) + .await?; + Ok((rows, total as u64)) + } else { + let total: i64 = sqlx::query_scalar("select count(*) from user_payment_method") + .fetch_one(&self.db) + .await?; + let rows = sqlx::query_as( + "select * from user_payment_method order by id desc limit ? offset ?", + ) + .bind(limit) + .bind(offset) + .fetch_all(&self.db) + .await?; + Ok((rows, total as u64)) + } } async fn update_user_payment_method(&self, pm: &UserPaymentMethod) -> DbResult<()> {