Skip to content
Merged
5 changes: 5 additions & 0 deletions API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Valid `cpu_arch` values: "x86_64", "arm64", "unknown"
- CPU features are parsed from strings (e.g. "AVX2", "AES", "VMX"); invalid values are silently ignored

- **2026-02-19** - Added Referral Program API endpoints
- `POST /api/v1/referral` - Enroll in referral program with lightning address or NWC payout options
- `GET /api/v1/referral` - Get referral state including per-currency earnings, payout history, and success/failed counts
- `PATCH /api/v1/referral` - Update payout options (lightning_address, use_nwc)

- **2026-02-17** - Added embedded API documentation served at root path (both User and Admin APIs)
- `GET /` or `GET /index.html` - Renders API documentation with markdown viewer
- `GET /docs/endpoints.md` - Raw markdown content of API endpoints documentation
Expand Down
65 changes: 65 additions & 0 deletions API_DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,71 @@ const result: ApiResponse<Subscription> = await response.json();
// Returns: PaginatedResponse<SubscriptionPayment>
```

### Referral Program

Users can enroll in the referral program to earn payouts when others sign up using their code.

#### Sign Up for Referral Program
- **POST** `/api/v1/referral`
- **Auth**: Required
- **Body**:
```typescript
interface ReferralSignupRequest {
lightning_address?: string; // Lightning address for automatic payouts
use_nwc?: boolean; // Use NWC wallet for payouts (requires NWC configured on account)
}
```
- **Response**: `Referral`
- **Error**: Returns error if already enrolled, or if no payout method is provided, or if `use_nwc` is true but NWC is not configured

#### Get Referral State
- **GET** `/api/v1/referral`
- **Auth**: Required
- **Response**: `ReferralState`

#### Update Referral Payout Options
- **PATCH** `/api/v1/referral`
- **Auth**: Required
- **Body**:
```typescript
interface ReferralPatchRequest {
lightning_address?: string | null; // Set or clear lightning address
use_nwc?: boolean; // Enable/disable NWC payout
}
```
- **Response**: `Referral`

**Response Types:**
```typescript
interface Referral {
code: string; // 8-character base63 referral code to share
lightning_address?: string; // Lightning address for payouts
use_nwc: boolean; // Whether to use NWC for payouts
created: string; // ISO 8601 datetime
}

interface ReferralEarning {
currency: string; // Currency code (e.g. "EUR", "BTC")
amount: number; // Total earned in this currency (smallest currency unit)
}

interface ReferralPayout {
id: number;
amount: number;
currency: string;
created: string; // ISO 8601 datetime
is_paid: boolean;
invoice?: string; // BOLT11 lightning invoice
}

interface ReferralState extends Referral {
earned: ReferralEarning[]; // Per-currency breakdown of referral earnings
payouts: ReferralPayout[]; // Complete payout history (most recent first)
referrals_success: number; // Number of referred users that made a payment
referrals_failed: number; // Number of referred users that never paid
}
```

### Monitoring and History

#### Get VM Time Series Data
Expand Down
2 changes: 2 additions & 0 deletions lnvps_api/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod ip_space;
mod model;
#[cfg(feature = "nostr-domain")]
mod nostr_domain;
mod referral;
mod routes;
mod subscriptions;
mod webhook;
Expand Down Expand Up @@ -46,6 +47,7 @@ use lnvps_api_common::{ExchangeRateService, VmHistoryLogger, VmStateCache, WorkC
use lnvps_db::LNVpsDb;
#[cfg(feature = "nostr-domain")]
pub use nostr_domain::router as nostr_domain_router;
pub use referral::router as referral_router;
pub use routes::routes as main_router;
use serde::Deserialize;
use std::sync::Arc;
Expand Down
283 changes: 283 additions & 0 deletions lnvps_api/src/api/referral.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
use axum::extract::State;
use axum::routing::get;
use axum::{Json, Router};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

use lnvps_api_common::{ApiData, ApiError, ApiResult, Nip98Auth};
use lnvps_db::{Referral, ReferralCostUsage, ReferralPayout};

use crate::api::RouterState;

pub fn router() -> Router<RouterState> {
Router::new().route(
"/api/v1/referral",
get(v1_get_referral)
.post(v1_signup_referral)
.patch(v1_update_referral),
)
}

/// Response type for a referral entry
#[derive(Serialize)]
pub struct ApiReferral {
/// The referral code to share with others
pub code: String,
/// Lightning address for automatic payouts
pub lightning_address: Option<String>,
/// Whether to use NWC for payouts
pub use_nwc: bool,
/// When the referral was created
pub created: chrono::DateTime<Utc>,
}

impl From<Referral> for ApiReferral {
fn from(r: Referral) -> Self {
Self {
code: r.code,
lightning_address: r.lightning_address,
use_nwc: r.use_nwc,
created: r.created,
}
}
}

/// Per-currency earned amount from referrals
#[derive(Serialize)]
pub struct ApiReferralEarning {
/// Currency code
pub currency: String,
/// Total earned amount in this currency (sum of first payments per referred VM)
pub amount: u64,
}

/// A single payout record
#[derive(Serialize)]
pub struct ApiReferralPayout {
pub id: u64,
pub amount: u64,
pub currency: String,
pub created: chrono::DateTime<Utc>,
pub is_paid: bool,
pub invoice: Option<String>,
}

impl From<ReferralPayout> for ApiReferralPayout {
fn from(p: ReferralPayout) -> Self {
Self {
id: p.id,
amount: p.amount,
currency: p.currency,
created: p.created,
is_paid: p.is_paid,
invoice: p.invoice,
}
}
}

/// Full referral state returned by GET /api/v1/referral
#[derive(Serialize)]
pub struct ApiReferralState {
#[serde(flatten)]
pub referral: ApiReferral,
/// Per-currency breakdown of amounts earned from referrals
pub earned: Vec<ApiReferralEarning>,
/// Complete payout history (most recent first)
pub payouts: Vec<ApiReferralPayout>,
/// Number of referred VMs that made at least one payment
pub referrals_success: u64,
/// Number of referred VMs that never made a payment
pub referrals_failed: u64,
}

impl ApiReferralState {
fn build(
referral: Referral,
usage: Vec<ReferralCostUsage>,
payouts: Vec<ReferralPayout>,
referrals_failed: u64,
) -> Self {
// Aggregate earned amounts per currency
let mut by_currency: HashMap<String, u64> = HashMap::new();
for u in &usage {
*by_currency.entry(u.currency.clone()).or_insert(0) += u.amount;
}
let mut earned: Vec<ApiReferralEarning> = by_currency
.into_iter()
.map(|(currency, amount)| ApiReferralEarning { currency, amount })
.collect();
earned.sort_by(|a, b| a.currency.cmp(&b.currency));

Self {
referrals_success: usage.len() as u64,
referrals_failed,
referral: referral.into(),
earned,
payouts: payouts.into_iter().map(Into::into).collect(),
}
}
}

/// Request to sign up for the referral program
#[derive(Deserialize)]
pub struct ApiReferralSignupRequest {
/// Lightning address for payouts (optional)
pub lightning_address: Option<String>,
/// Use NWC connection for payouts
#[serde(default)]
pub use_nwc: bool,
}

/// Request to update referral payout options
#[derive(Deserialize)]
pub struct ApiReferralPatchRequest {
/// Lightning address for payouts (None = clear, Some(s) = set)
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "lnvps_api_common::deserialize_nullable_option"
)]
pub lightning_address: Option<Option<String>>,
/// Use NWC connection for payouts
pub use_nwc: Option<bool>,
}

/// Generate a random 8-character base63 referral code (A-Za-z0-9_)
fn generate_referral_code() -> String {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_";
let bytes: [u8; 8] = rand::random();
bytes
.iter()
.map(|&b| ALPHABET[(b as usize) % ALPHABET.len()] as char)
.collect()
}

/// Get current referral state (code, per-currency earnings, payout history, counts)
async fn v1_get_referral(
auth: Nip98Auth,
State(this): State<RouterState>,
) -> ApiResult<ApiReferralState> {
let pubkey = auth.event.pubkey.to_bytes();
let uid = this.db.upsert_user(&pubkey).await?;

let referral = this
.db
.get_referral_by_user(uid)
.await
.map_err(|_| ApiError::new("Not enrolled in referral program"))?;

let (usage, payouts, referrals_failed) = tokio::try_join!(
this.db.list_referral_usage(&referral.code),
this.db.list_referral_payouts(referral.id),
this.db.count_failed_referrals(&referral.code),
)?;

ApiData::ok(ApiReferralState::build(referral, usage, payouts, referrals_failed))
}

/// Sign up for the referral program
async fn v1_signup_referral(
auth: Nip98Auth,
State(this): State<RouterState>,
Json(req): Json<ApiReferralSignupRequest>,
) -> ApiResult<ApiReferral> {
let pubkey = auth.event.pubkey.to_bytes();
let uid = this.db.upsert_user(&pubkey).await?;

// Check if already enrolled
if this.db.get_referral_by_user(uid).await.is_ok() {
return ApiData::err("Already enrolled in referral program");
}

// Validate that at least one payout method is specified
if req.lightning_address.is_none() && !req.use_nwc {
return ApiData::err("At least one payout method (lightning_address or use_nwc) is required");
}

// 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");
}
}

let code = generate_referral_code();
let referral = Referral {
id: 0,
user_id: uid,
code,
lightning_address: req.lightning_address,
use_nwc: req.use_nwc,
created: Utc::now(),
};

let id = this.db.insert_referral(&referral).await?;
let created = Referral { id, ..referral };

ApiData::ok(created.into())
}

/// Update referral payout options
async fn v1_update_referral(
auth: Nip98Auth,
State(this): State<RouterState>,
Json(req): Json<ApiReferralPatchRequest>,
) -> ApiResult<ApiReferral> {
let pubkey = auth.event.pubkey.to_bytes();
let uid = this.db.upsert_user(&pubkey).await?;

let mut referral = this
.db
.get_referral_by_user(uid)
.await
.map_err(|_| ApiError::new("Not enrolled in referral program"))?;

if let Some(addr) = req.lightning_address {
referral.lightning_address = addr;
}
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");
}
}
referral.use_nwc = use_nwc;
}

this.db.update_referral(&referral).await?;

ApiData::ok(referral.into())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_generate_referral_code_length() {
let code = generate_referral_code();
assert_eq!(code.len(), 8);
}

#[test]
fn test_generate_referral_code_alphabet() {
const VALID: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_";
for _ in 0..100 {
let code = generate_referral_code();
for c in code.chars() {
assert!(VALID.contains(c), "Invalid base63 character: {}", c);
}
}
}

#[test]
fn test_generate_referral_codes_are_random() {
// Generate 20 codes and ensure they are not all identical
let codes: Vec<String> = (0..20).map(|_| generate_referral_code()).collect();
let unique: std::collections::HashSet<&String> = codes.iter().collect();
assert!(unique.len() > 1, "All generated codes were identical");
}
}
3 changes: 2 additions & 1 deletion lnvps_api/src/bin/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,8 @@ async fn main() -> Result<(), Error> {
.merge(contacts_router())
.merge(webhook_router())
.merge(subscriptions_router())
.merge(ip_space_router());
.merge(ip_space_router())
.merge(referral_router());

#[cfg(feature = "openapi")]
{
Expand Down
Loading
Loading