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
93 changes: 93 additions & 0 deletions ADMIN_API_ENDPOINTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions lnvps_api/examples/revolut_sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand All @@ -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?;
Expand All @@ -147,7 +144,10 @@ async fn main() -> Result<()> {
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);
println!(
"PAYMENT_METHOD_ID={} TYPE={:?} SAVED_FOR={:?}",
m.id, m.kind, m.saved_for
);
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion lnvps_api/src/api/subscriptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 1 addition & 4 deletions lnvps_api/src/bin/fix_lnurlp_topups.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
2 changes: 1 addition & 1 deletion lnvps_api/src/data_migration/dns.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
2 changes: 1 addition & 1 deletion lnvps_api/src/host/proxmox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
3 changes: 1 addition & 2 deletions lnvps_api/src/notifications/nip17.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()))?;
Expand Down
8 changes: 5 additions & 3 deletions lnvps_api/src/notifications/telegram.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()))),
}
}
Expand Down
8 changes: 5 additions & 3 deletions lnvps_api/src/notifications/whatsapp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()))),
}
}
Expand Down
5 changes: 4 additions & 1 deletion lnvps_api/src/payments/revolut.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lnvps_api/src/provisioner/retry_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion lnvps_api/src/provisioner/vm_network.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
2 changes: 1 addition & 1 deletion lnvps_api/src/router/mikrotik.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions lnvps_api/src/router/ovh.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
16 changes: 8 additions & 8 deletions lnvps_api/src/subscription/vm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion lnvps_api_admin/src/admin/cost_plans.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
14 changes: 10 additions & 4 deletions lnvps_api_admin/src/admin/hosts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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())
Expand All @@ -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
Expand Down
8 changes: 6 additions & 2 deletions lnvps_api_admin/src/admin/ip_space.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading