From 7ea42a826e3bc2e2c8ee4d9c57bae97ad6c6c311 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:14:46 +0000 Subject: [PATCH 1/9] Initial plan From 58d5a98790be1a45d32bd6fd9c9cb6562c8cbc9f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:27:23 +0000 Subject: [PATCH 2/9] Implement referral program: DB models, trait, MySQL, mock, and API endpoints Co-authored-by: v0l <1172179+v0l@users.noreply.github.com> --- lnvps_api/src/api/mod.rs | 2 + lnvps_api/src/api/referral.rs | 236 ++++++++++++++++++ lnvps_api/src/bin/api.rs | 3 +- lnvps_api_common/src/mock.rs | 115 ++++++++- .../20260219000000_referral_program.sql | 24 ++ lnvps_db/src/lib.rs | 28 +++ lnvps_db/src/model.rs | 46 ++++ lnvps_db/src/mysql.rs | 112 ++++++++- 8 files changed, 556 insertions(+), 10 deletions(-) create mode 100644 lnvps_api/src/api/referral.rs create mode 100644 lnvps_db/migrations/20260219000000_referral_program.sql diff --git a/lnvps_api/src/api/mod.rs b/lnvps_api/src/api/mod.rs index ed312f67..1bd3357c 100644 --- a/lnvps_api/src/api/mod.rs +++ b/lnvps_api/src/api/mod.rs @@ -4,6 +4,7 @@ mod ip_space; mod model; #[cfg(feature = "nostr-domain")] mod nostr_domain; +mod referral; mod routes; mod subscriptions; mod webhook; @@ -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; diff --git a/lnvps_api/src/api/referral.rs b/lnvps_api/src/api/referral.rs new file mode 100644 index 00000000..e04a3883 --- /dev/null +++ b/lnvps_api/src/api/referral.rs @@ -0,0 +1,236 @@ +use crate::api::RouterState; +use axum::Json; +use axum::Router; +use axum::extract::State; +use axum::routing::{get, patch, post}; +use chrono::Utc; +use lnvps_api_common::{ApiData, ApiError, ApiResult, Nip98Auth}; +use lnvps_db::{Referral, ReferralSummary}; +use serde::{Deserialize, Serialize}; + +pub fn router() -> Router { + 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, + /// Whether to use NWC for payouts + pub use_nwc: bool, + /// When the referral was created + pub created: chrono::DateTime, +} + +impl From for ApiReferral { + fn from(r: Referral) -> Self { + Self { + code: r.code, + lightning_address: r.lightning_address, + use_nwc: r.use_nwc, + created: r.created, + } + } +} + +/// Response type combining referral info and summary stats +#[derive(Serialize)] +pub struct ApiReferralState { + #[serde(flatten)] + pub referral: ApiReferral, + /// Total amount pending payout (earned but not yet paid) + pub pending_amount: u64, + /// Total lifetime paid amount + pub paid_amount: u64, + /// Number of referrals that resulted in a paid subscription + pub referrals_success: u64, + /// Number of referrals that never paid + pub referrals_failed: u64, +} + +impl ApiReferralState { + pub fn from_referral_and_summary(referral: Referral, summary: ReferralSummary) -> Self { + Self { + referral: referral.into(), + pending_amount: summary.pending_amount, + paid_amount: summary.paid_amount, + referrals_success: summary.referrals_success, + referrals_failed: summary.referrals_failed, + } + } +} + +/// Request to sign up for the referral program +#[derive(Deserialize)] +pub struct ApiReferralSignupRequest { + /// Lightning address for payouts (optional) + pub lightning_address: Option, + /// 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>, + /// Use NWC connection for payouts + pub use_nwc: Option, +} + +/// Generate a random 8-character base32 referral code +fn generate_referral_code() -> String { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let bytes: [u8; 5] = rand::random(); + // Encode 5 bytes (40 bits) as 8 base32 characters (5 bits each) + let bits = (bytes[0] as u64) << 32 + | (bytes[1] as u64) << 24 + | (bytes[2] as u64) << 16 + | (bytes[3] as u64) << 8 + | bytes[4] as u64; + let mut code = String::with_capacity(8); + for i in (0..8).rev() { + let idx = ((bits >> (i * 5)) & 0x1f) as usize; + code.push(ALPHABET[idx] as char); + } + code +} + +/// Get current referral state (code, stats, payout info) +async fn v1_get_referral( + auth: Nip98Auth, + State(this): State, +) -> ApiResult { + 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 summary = this.db.get_referral_summary(referral.id).await?; + + ApiData::ok(ApiReferralState::from_referral_and_summary(referral, summary)) +} + +/// Sign up for the referral program +async fn v1_signup_referral( + auth: Nip98Auth, + State(this): State, + Json(req): Json, +) -> ApiResult { + 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, + Json(req): Json, +) -> ApiResult { + 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 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + for _ in 0..100 { + let code = generate_referral_code(); + for c in code.chars() { + assert!(VALID.contains(c), "Invalid base32 character: {}", c); + } + } + } + + #[test] + fn test_generate_referral_codes_are_random() { + // Generate 20 codes and ensure they are not all identical + let codes: Vec = (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"); + } +} diff --git a/lnvps_api/src/bin/api.rs b/lnvps_api/src/bin/api.rs index 684ba972..82d7de53 100644 --- a/lnvps_api/src/bin/api.rs +++ b/lnvps_api/src/bin/api.rs @@ -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")] { diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index b0cca176..4da23418 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -5,10 +5,11 @@ use lnvps_db::nostr::LNVPSNostrDb; use lnvps_db::{ AccessPolicy, AvailableIpSpace, Company, CpuArch, CpuMfg, DbResult, DiskInterface, DiskType, IpRange, IpRangeAllocationMode, IpRangeSubscription, IpSpacePricing, LNVpsDbBase, NostrDomain, - NostrDomainHandle, OsDistribution, PaymentMethod, PaymentMethodConfig, Router, Subscription, - SubscriptionLineItem, SubscriptionPayment, SubscriptionPaymentWithCompany, User, UserSshKey, - Vm, VmCostPlan, VmCostPlanIntervalType, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, - VmHistory, VmHost, VmHostDisk, VmHostKind, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, + NostrDomainHandle, OsDistribution, PaymentMethod, PaymentMethodConfig, Referral, + ReferralPayout, ReferralSummary, Router, Subscription, SubscriptionLineItem, + SubscriptionPayment, SubscriptionPaymentWithCompany, User, UserSshKey, Vm, VmCostPlan, + VmCostPlanIntervalType, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHistory, + VmHost, VmHostDisk, VmHostKind, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmTemplate, }; @@ -47,6 +48,8 @@ pub struct MockDb { pub subscription_line_items: Arc>>, pub subscription_payments: Arc>>, pub payment_method_configs: Arc>>, + pub referrals: Arc>>, + pub referral_payouts: Arc>>, } impl MockDb { @@ -246,6 +249,8 @@ impl Default for MockDb { subscription_line_items: Arc::new(Default::default()), subscription_payments: Arc::new(Default::default()), payment_method_configs: Arc::new(Default::default()), + referrals: Arc::new(Default::default()), + referral_payouts: Arc::new(Default::default()), } } } @@ -1476,6 +1481,108 @@ impl LNVpsDbBase for MockDb { configs.remove(&id); Ok(()) } + + async fn get_referral_by_user(&self, user_id: u64) -> DbResult { + let referrals = self.referrals.lock().await; + referrals + .values() + .find(|r| r.user_id == user_id) + .cloned() + .ok_or_else(|| anyhow!("Referral not found for user {}", user_id).into()) + } + + async fn get_referral_by_code(&self, code: &str) -> DbResult { + let referrals = self.referrals.lock().await; + referrals + .values() + .find(|r| r.code == code) + .cloned() + .ok_or_else(|| anyhow!("Referral not found for code {}", code).into()) + } + + async fn insert_referral(&self, referral: &Referral) -> DbResult { + let mut referrals = self.referrals.lock().await; + let max_id = referrals.keys().max().copied().unwrap_or(0); + let new_id = max_id + 1; + referrals.insert(new_id, Referral { id: new_id, ..referral.clone() }); + Ok(new_id) + } + + async fn update_referral(&self, referral: &Referral) -> DbResult<()> { + let mut referrals = self.referrals.lock().await; + if let Some(r) = referrals.get_mut(&referral.id) { + r.lightning_address = referral.lightning_address.clone(); + r.use_nwc = referral.use_nwc; + } + Ok(()) + } + + async fn insert_referral_payout(&self, payout: &ReferralPayout) -> DbResult { + let mut payouts = self.referral_payouts.lock().await; + let new_id = payouts.len() as u64 + 1; + payouts.push(ReferralPayout { id: new_id, ..payout.clone() }); + Ok(new_id) + } + + async fn update_referral_payout(&self, payout: &ReferralPayout) -> DbResult<()> { + let mut payouts = self.referral_payouts.lock().await; + if let Some(p) = payouts.iter_mut().find(|p| p.id == payout.id) { + p.is_paid = payout.is_paid; + } + Ok(()) + } + + async fn list_referral_payouts(&self, referral_id: u64) -> DbResult> { + let payouts = self.referral_payouts.lock().await; + Ok(payouts + .iter() + .filter(|p| p.referral_id == referral_id) + .cloned() + .collect()) + } + + async fn get_referral_summary(&self, referral_id: u64) -> DbResult { + let referrals = self.referrals.lock().await; + let referral = referrals + .get(&referral_id) + .cloned() + .ok_or_else(|| anyhow!("Referral not found: {}", referral_id))?; + drop(referrals); + + let payouts = self.referral_payouts.lock().await; + let pending_amount = payouts + .iter() + .filter(|p| p.referral_id == referral_id && !p.is_paid) + .map(|p| p.amount) + .sum(); + let paid_amount = payouts + .iter() + .filter(|p| p.referral_id == referral_id && p.is_paid) + .map(|p| p.amount) + .sum(); + drop(payouts); + + let vms = self.vms.lock().await; + let payments = self.payments.lock().await; + let referrals_success = vms + .values() + .filter(|v| v.ref_code.as_deref() == Some(&referral.code)) + .filter(|v| payments.iter().any(|p| p.vm_id == v.id && p.is_paid)) + .count() as u64; + let referrals_failed = vms + .values() + .filter(|v| v.ref_code.as_deref() == Some(&referral.code)) + .filter(|v| !payments.iter().any(|p| p.vm_id == v.id && p.is_paid)) + .count() as u64; + + Ok(ReferralSummary { + code: referral.code, + pending_amount, + paid_amount, + referrals_success, + referrals_failed, + }) + } } pub struct MockExchangeRate { diff --git a/lnvps_db/migrations/20260219000000_referral_program.sql b/lnvps_db/migrations/20260219000000_referral_program.sql new file mode 100644 index 00000000..372ae797 --- /dev/null +++ b/lnvps_db/migrations/20260219000000_referral_program.sql @@ -0,0 +1,24 @@ +-- Referral program tables +CREATE TABLE referral ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + code VARCHAR(20) NOT NULL, + lightning_address VARCHAR(200), + use_nwc BOOLEAN NOT NULL DEFAULT FALSE, + created DATETIME NOT NULL DEFAULT NOW(), + PRIMARY KEY (id), + UNIQUE KEY uk_referral_code (code), + UNIQUE KEY uk_referral_user (user_id), + FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE TABLE referral_payout ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + referral_id BIGINT UNSIGNED NOT NULL, + amount BIGINT UNSIGNED NOT NULL, + currency VARCHAR(10) NOT NULL, + created DATETIME NOT NULL DEFAULT NOW(), + is_paid BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (id), + FOREIGN KEY (referral_id) REFERENCES referral(id) +); diff --git a/lnvps_db/src/lib.rs b/lnvps_db/src/lib.rs index 69690a22..77735a06 100644 --- a/lnvps_db/src/lib.rs +++ b/lnvps_db/src/lib.rs @@ -506,6 +506,34 @@ pub trait LNVpsDbBase: Send + Sync { /// Delete a payment method configuration async fn delete_payment_method_config(&self, id: u64) -> DbResult<()>; + + // ======================================================================== + // Referral Program + // ======================================================================== + + /// Get a user's referral entry by user id + async fn get_referral_by_user(&self, user_id: u64) -> DbResult; + + /// Get a referral entry by its code + async fn get_referral_by_code(&self, code: &str) -> DbResult; + + /// Insert a new referral entry + async fn insert_referral(&self, referral: &Referral) -> DbResult; + + /// Update an existing referral entry + async fn update_referral(&self, referral: &Referral) -> DbResult<()>; + + /// Insert a new referral payout record + async fn insert_referral_payout(&self, payout: &ReferralPayout) -> DbResult; + + /// Update a referral payout record + async fn update_referral_payout(&self, payout: &ReferralPayout) -> DbResult<()>; + + /// List all payout records for a referral + async fn list_referral_payouts(&self, referral_id: u64) -> DbResult>; + + /// Get summary stats for a referral (single query) + async fn get_referral_summary(&self, referral_id: u64) -> DbResult; } /// Super trait that combines all database functionality based on enabled features diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index 5a15c3e1..ad95ea04 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -899,6 +899,52 @@ pub struct VmPayment { pub upgrade_params: Option, } +#[derive(FromRow, Clone, Debug, Default)] +pub struct Referral { + /// Unique id of this referral entry + pub id: u64, + /// The user that owns this referral + pub user_id: u64, + /// The auto-generated referral code (base32, 8 characters) + pub code: String, + /// Lightning address for automatic payouts + pub lightning_address: Option, + /// If true, use the user's NWC connection for payouts + pub use_nwc: bool, + /// When this referral entry was created + pub created: DateTime, +} + +#[derive(FromRow, Clone, Debug, Default)] +pub struct ReferralPayout { + /// Unique id of this payout record + pub id: u64, + /// The referral this payout belongs to + pub referral_id: u64, + /// Amount in smallest currency unit + pub amount: u64, + /// Currency of this payout + pub currency: String, + /// When this payout record was created + pub created: DateTime, + /// Whether this payout has been completed + pub is_paid: bool, +} + +#[derive(FromRow, Clone, Debug, Default)] +pub struct ReferralSummary { + /// The referral code + pub code: String, + /// Total pending payout amount (created but not yet paid) + pub pending_amount: u64, + /// Total lifetime paid amount + pub paid_amount: u64, + /// Number of VMs that used this referral code and made at least one payment + pub referrals_success: u64, + /// Number of VMs that used this referral code but never made a payment + pub referrals_failed: u64, +} + #[derive(FromRow, Clone, Debug)] pub struct ReferralCostUsage { pub vm_id: u64, diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index 4d8b36be..becbb1ae 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -1,10 +1,11 @@ use crate::{ AccessPolicy, AvailableIpSpace, Company, DbError, DbResult, IpRange, IpRangeSubscription, - IpSpacePricing, LNVpsDbBase, PaymentMethod, PaymentMethodConfig, PaymentType, - ReferralCostUsage, RegionStats, Router, Subscription, SubscriptionLineItem, - SubscriptionPayment, SubscriptionPaymentWithCompany, User, UserSshKey, Vm, VmCostPlan, - VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHistory, VmHost, VmHostDisk, - VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmPaymentWithCompany, VmTemplate, + IpSpacePricing, LNVpsDbBase, PaymentMethod, PaymentMethodConfig, PaymentType, Referral, + ReferralCostUsage, ReferralPayout, ReferralSummary, RegionStats, Router, Subscription, + SubscriptionLineItem, SubscriptionPayment, SubscriptionPaymentWithCompany, User, UserSshKey, + Vm, VmCostPlan, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHistory, VmHost, + VmHostDisk, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmPaymentWithCompany, + VmTemplate, }; #[cfg(feature = "admin")] use crate::{AdminDb, AdminRole, AdminRoleAssignment, AdminVmHost}; @@ -1792,6 +1793,107 @@ impl LNVpsDbBase for LNVpsDbMysql { Ok(()) } + + async fn get_referral_by_user(&self, user_id: u64) -> DbResult { + Ok(sqlx::query_as("SELECT * FROM referral WHERE user_id = ?") + .bind(user_id) + .fetch_one(&self.db) + .await?) + } + + async fn get_referral_by_code(&self, code: &str) -> DbResult { + Ok(sqlx::query_as("SELECT * FROM referral WHERE code = ?") + .bind(code) + .fetch_one(&self.db) + .await?) + } + + async fn insert_referral(&self, referral: &Referral) -> DbResult { + let res = sqlx::query( + "INSERT INTO referral (user_id, code, lightning_address, use_nwc) VALUES (?, ?, ?, ?) returning id", + ) + .bind(referral.user_id) + .bind(&referral.code) + .bind(&referral.lightning_address) + .bind(referral.use_nwc) + .fetch_one(&self.db) + .await?; + Ok(res.try_get(0)?) + } + + async fn update_referral(&self, referral: &Referral) -> DbResult<()> { + sqlx::query( + "UPDATE referral SET lightning_address = ?, use_nwc = ? WHERE id = ?", + ) + .bind(&referral.lightning_address) + .bind(referral.use_nwc) + .bind(referral.id) + .execute(&self.db) + .await?; + Ok(()) + } + + async fn insert_referral_payout(&self, payout: &ReferralPayout) -> DbResult { + let res = sqlx::query( + "INSERT INTO referral_payout (referral_id, amount, currency) VALUES (?, ?, ?) returning id", + ) + .bind(payout.referral_id) + .bind(payout.amount) + .bind(&payout.currency) + .fetch_one(&self.db) + .await?; + Ok(res.try_get(0)?) + } + + async fn update_referral_payout(&self, payout: &ReferralPayout) -> DbResult<()> { + sqlx::query("UPDATE referral_payout SET is_paid = ? WHERE id = ?") + .bind(payout.is_paid) + .bind(payout.id) + .execute(&self.db) + .await?; + Ok(()) + } + + async fn list_referral_payouts(&self, referral_id: u64) -> DbResult> { + Ok( + sqlx::query_as("SELECT * FROM referral_payout WHERE referral_id = ? ORDER BY created DESC") + .bind(referral_id) + .fetch_all(&self.db) + .await?, + ) + } + + async fn get_referral_summary(&self, referral_id: u64) -> DbResult { + Ok(sqlx::query_as( + "SELECT + r.code, + CAST(COALESCE(SUM(CASE WHEN rp.is_paid = 0 THEN rp.amount ELSE 0 END), 0) AS UNSIGNED) AS pending_amount, + CAST(COALESCE(SUM(CASE WHEN rp.is_paid = 1 THEN rp.amount ELSE 0 END), 0) AS UNSIGNED) AS paid_amount, + CAST(COALESCE(( + SELECT COUNT(DISTINCT v.id) + FROM vm v + WHERE v.ref_code = r.code + AND EXISTS ( + SELECT 1 FROM vm_payment vp WHERE vp.vm_id = v.id AND vp.is_paid = 1 + ) + ), 0) AS UNSIGNED) AS referrals_success, + CAST(COALESCE(( + SELECT COUNT(DISTINCT v.id) + FROM vm v + WHERE v.ref_code = r.code + AND NOT EXISTS ( + SELECT 1 FROM vm_payment vp WHERE vp.vm_id = v.id AND vp.is_paid = 1 + ) + ), 0) AS UNSIGNED) AS referrals_failed + FROM referral r + LEFT JOIN referral_payout rp ON rp.referral_id = r.id + WHERE r.id = ? + GROUP BY r.id, r.code", + ) + .bind(referral_id) + .fetch_one(&self.db) + .await?) + } } #[cfg(feature = "nostr-domain")] From af461b73556a9cf94a910ed79fa7382bc80e9c6d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:32:32 +0000 Subject: [PATCH 3/9] Add referral program API documentation to API_DOCUMENTATION.md Co-authored-by: v0l <1172179+v0l@users.noreply.github.com> --- API_DOCUMENTATION.md | 51 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index d0ab8183..47053fe6 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -621,6 +621,57 @@ const result: ApiResponse = await response.json(); // Returns: PaginatedResponse ``` +### 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 base32 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 ReferralState extends Referral { + pending_amount: number; // Total earned but not yet paid out (smallest currency unit) + paid_amount: number; // Total lifetime paid out (smallest currency unit) + referrals_success: number; // Number of users that signed up and made a payment + referrals_failed: number; // Number of users that signed up but never paid +} +``` + ### Monitoring and History #### Get VM Time Series Data From 7dd1d35a2dca1cffebd854890a9a6b8c15122940 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:46:27 +0000 Subject: [PATCH 4/9] Address review: base63 codes, invoice/pre_image in payout, fix summary calculation Co-authored-by: v0l <1172179+v0l@users.noreply.github.com> --- lnvps_api/src/api/referral.rs | 26 ++++------- lnvps_api_common/src/mock.rs | 46 +++++++++++++------ .../20260219000000_referral_program.sql | 2 + lnvps_db/src/model.rs | 4 ++ lnvps_db/src/mysql.rs | 32 ++++++++++--- 5 files changed, 72 insertions(+), 38 deletions(-) diff --git a/lnvps_api/src/api/referral.rs b/lnvps_api/src/api/referral.rs index e04a3883..14150dde 100644 --- a/lnvps_api/src/api/referral.rs +++ b/lnvps_api/src/api/referral.rs @@ -92,22 +92,14 @@ pub struct ApiReferralPatchRequest { pub use_nwc: Option, } -/// Generate a random 8-character base32 referral code +/// Generate a random 8-character base63 referral code (A-Za-z0-9_) fn generate_referral_code() -> String { - const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; - let bytes: [u8; 5] = rand::random(); - // Encode 5 bytes (40 bits) as 8 base32 characters (5 bits each) - let bits = (bytes[0] as u64) << 32 - | (bytes[1] as u64) << 24 - | (bytes[2] as u64) << 16 - | (bytes[3] as u64) << 8 - | bytes[4] as u64; - let mut code = String::with_capacity(8); - for i in (0..8).rev() { - let idx = ((bits >> (i * 5)) & 0x1f) as usize; - code.push(ALPHABET[idx] as char); - } - code + 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, stats, payout info) @@ -217,11 +209,11 @@ mod tests { #[test] fn test_generate_referral_code_alphabet() { - const VALID: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + const VALID: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_"; for _ in 0..100 { let code = generate_referral_code(); for c in code.chars() { - assert!(VALID.contains(c), "Invalid base32 character: {}", c); + assert!(VALID.contains(c), "Invalid base63 character: {}", c); } } } diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index 4da23418..abdb4862 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -1520,7 +1520,10 @@ impl LNVpsDbBase for MockDb { async fn insert_referral_payout(&self, payout: &ReferralPayout) -> DbResult { let mut payouts = self.referral_payouts.lock().await; let new_id = payouts.len() as u64 + 1; - payouts.push(ReferralPayout { id: new_id, ..payout.clone() }); + payouts.push(ReferralPayout { + id: new_id, + ..payout.clone() + }); Ok(new_id) } @@ -1528,6 +1531,7 @@ impl LNVpsDbBase for MockDb { let mut payouts = self.referral_payouts.lock().await; if let Some(p) = payouts.iter_mut().find(|p| p.id == payout.id) { p.is_paid = payout.is_paid; + p.pre_image = payout.pre_image.clone(); } Ok(()) } @@ -1549,21 +1553,23 @@ impl LNVpsDbBase for MockDb { .ok_or_else(|| anyhow!("Referral not found: {}", referral_id))?; drop(referrals); - let payouts = self.referral_payouts.lock().await; - let pending_amount = payouts - .iter() - .filter(|p| p.referral_id == referral_id && !p.is_paid) - .map(|p| p.amount) - .sum(); - let paid_amount = payouts - .iter() - .filter(|p| p.referral_id == referral_id && p.is_paid) - .map(|p| p.amount) - .sum(); - drop(payouts); - + // Compute total_amount as sum of first paid payment per VM with this ref code let vms = self.vms.lock().await; let payments = self.payments.lock().await; + let total_amount: u64 = vms + .values() + .filter(|v| v.ref_code.as_deref() == Some(&referral.code)) + .filter_map(|v| { + // Get the first paid payment for this VM + let mut vm_payments: Vec<&VmPayment> = payments + .iter() + .filter(|p| p.vm_id == v.id && p.is_paid) + .collect(); + vm_payments.sort_by_key(|p| p.created); + vm_payments.first().map(|p| p.amount) + }) + .sum(); + let referrals_success = vms .values() .filter(|v| v.ref_code.as_deref() == Some(&referral.code)) @@ -1574,6 +1580,18 @@ impl LNVpsDbBase for MockDb { .filter(|v| v.ref_code.as_deref() == Some(&referral.code)) .filter(|v| !payments.iter().any(|p| p.vm_id == v.id && p.is_paid)) .count() as u64; + drop(payments); + drop(vms); + + let payouts = self.referral_payouts.lock().await; + let paid_amount: u64 = payouts + .iter() + .filter(|p| p.referral_id == referral_id && p.is_paid) + .map(|p| p.amount) + .sum(); + drop(payouts); + + let pending_amount = total_amount.saturating_sub(paid_amount); Ok(ReferralSummary { code: referral.code, diff --git a/lnvps_db/migrations/20260219000000_referral_program.sql b/lnvps_db/migrations/20260219000000_referral_program.sql index 372ae797..49ee4ff3 100644 --- a/lnvps_db/migrations/20260219000000_referral_program.sql +++ b/lnvps_db/migrations/20260219000000_referral_program.sql @@ -19,6 +19,8 @@ CREATE TABLE referral_payout ( currency VARCHAR(10) NOT NULL, created DATETIME NOT NULL DEFAULT NOW(), is_paid BOOLEAN NOT NULL DEFAULT FALSE, + invoice VARCHAR(600), + pre_image VARCHAR(64), PRIMARY KEY (id), FOREIGN KEY (referral_id) REFERENCES referral(id) ); diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index ad95ea04..bf164426 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -929,6 +929,10 @@ pub struct ReferralPayout { pub created: DateTime, /// Whether this payout has been completed pub is_paid: bool, + /// Lightning invoice for this payout + pub invoice: Option, + /// Preimage revealed when the invoice was paid + pub pre_image: Option, } #[derive(FromRow, Clone, Debug, Default)] diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index becbb1ae..1e1857fa 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -1835,19 +1835,21 @@ impl LNVpsDbBase for LNVpsDbMysql { async fn insert_referral_payout(&self, payout: &ReferralPayout) -> DbResult { let res = sqlx::query( - "INSERT INTO referral_payout (referral_id, amount, currency) VALUES (?, ?, ?) returning id", + "INSERT INTO referral_payout (referral_id, amount, currency, invoice) VALUES (?, ?, ?, ?) returning id", ) .bind(payout.referral_id) .bind(payout.amount) .bind(&payout.currency) + .bind(&payout.invoice) .fetch_one(&self.db) .await?; Ok(res.try_get(0)?) } async fn update_referral_payout(&self, payout: &ReferralPayout) -> DbResult<()> { - sqlx::query("UPDATE referral_payout SET is_paid = ? WHERE id = ?") + sqlx::query("UPDATE referral_payout SET is_paid = ?, pre_image = ? WHERE id = ?") .bind(payout.is_paid) + .bind(&payout.pre_image) .bind(payout.id) .execute(&self.db) .await?; @@ -1867,8 +1869,8 @@ impl LNVpsDbBase for LNVpsDbMysql { Ok(sqlx::query_as( "SELECT r.code, - CAST(COALESCE(SUM(CASE WHEN rp.is_paid = 0 THEN rp.amount ELSE 0 END), 0) AS UNSIGNED) AS pending_amount, - CAST(COALESCE(SUM(CASE WHEN rp.is_paid = 1 THEN rp.amount ELSE 0 END), 0) AS UNSIGNED) AS paid_amount, + CAST(GREATEST(0, COALESCE(first_payments.total_amount, 0) - COALESCE(paid_payouts.paid_amount, 0)) AS UNSIGNED) AS pending_amount, + CAST(COALESCE(paid_payouts.paid_amount, 0) AS UNSIGNED) AS paid_amount, CAST(COALESCE(( SELECT COUNT(DISTINCT v.id) FROM vm v @@ -1886,11 +1888,27 @@ impl LNVpsDbBase for LNVpsDbMysql { ) ), 0) AS UNSIGNED) AS referrals_failed FROM referral r - LEFT JOIN referral_payout rp ON rp.referral_id = r.id - WHERE r.id = ? - GROUP BY r.id, r.code", + LEFT JOIN ( + SELECT SUM(vp.amount) AS total_amount + FROM vm v + JOIN ( + SELECT vm_id, amount, + ROW_NUMBER() OVER (PARTITION BY vm_id ORDER BY created ASC) AS rn + FROM vm_payment + WHERE is_paid = 1 + ) vp ON v.id = vp.vm_id AND vp.rn = 1 + WHERE v.ref_code = (SELECT code FROM referral WHERE id = ?) + ) first_payments ON TRUE + LEFT JOIN ( + SELECT SUM(amount) AS paid_amount + FROM referral_payout + WHERE referral_id = ? AND is_paid = 1 + ) paid_payouts ON TRUE + WHERE r.id = ?", ) .bind(referral_id) + .bind(referral_id) + .bind(referral_id) .fetch_one(&self.db) .await?) } From aa681be244bdf4d0161c87e89acc342ab2084cf9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:23:17 +0000 Subject: [PATCH 5/9] Fix referral_payout column types: VARCHAR(5) currency, VARCHAR(2048) invoice, BINARY(32) pre_image Co-authored-by: v0l <1172179+v0l@users.noreply.github.com> --- lnvps_db/migrations/20260219000000_referral_program.sql | 6 +++--- lnvps_db/src/model.rs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lnvps_db/migrations/20260219000000_referral_program.sql b/lnvps_db/migrations/20260219000000_referral_program.sql index 49ee4ff3..7345cfd3 100644 --- a/lnvps_db/migrations/20260219000000_referral_program.sql +++ b/lnvps_db/migrations/20260219000000_referral_program.sql @@ -16,11 +16,11 @@ CREATE TABLE referral_payout ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, referral_id BIGINT UNSIGNED NOT NULL, amount BIGINT UNSIGNED NOT NULL, - currency VARCHAR(10) NOT NULL, + currency VARCHAR(5) NOT NULL, created DATETIME NOT NULL DEFAULT NOW(), is_paid BOOLEAN NOT NULL DEFAULT FALSE, - invoice VARCHAR(600), - pre_image VARCHAR(64), + invoice VARCHAR(2048), + pre_image BINARY(32), PRIMARY KEY (id), FOREIGN KEY (referral_id) REFERENCES referral(id) ); diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index bf164426..a77139b3 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -931,8 +931,8 @@ pub struct ReferralPayout { pub is_paid: bool, /// Lightning invoice for this payout pub invoice: Option, - /// Preimage revealed when the invoice was paid - pub pre_image: Option, + /// Preimage revealed when the invoice was paid (32 bytes, SHA256) + pub pre_image: Option>, } #[derive(FromRow, Clone, Debug, Default)] From 82949fb4c84f9727dff963b546a4d391a99c95e0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:45:10 +0000 Subject: [PATCH 6/9] Redesign referral state: per-currency earnings, payout history, remove single-currency summary Co-authored-by: v0l <1172179+v0l@users.noreply.github.com> --- API_DOCUMENTATION.md | 24 ++++++++-- lnvps_api/src/api/referral.rs | 86 ++++++++++++++++++++++++++------- lnvps_api_common/src/mock.rs | 81 ++++++++++++------------------- lnvps_db/src/lib.rs | 8 +++- lnvps_db/src/model.rs | 14 ------ lnvps_db/src/mysql.rs | 90 ++++++++++++++++------------------- 6 files changed, 166 insertions(+), 137 deletions(-) diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index 47053fe6..9ff9286b 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -658,17 +658,31 @@ interface ReferralPatchRequest { **Response Types:** ```typescript interface Referral { - code: string; // 8-character base32 referral code to share + 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 { - pending_amount: number; // Total earned but not yet paid out (smallest currency unit) - paid_amount: number; // Total lifetime paid out (smallest currency unit) - referrals_success: number; // Number of users that signed up and made a payment - referrals_failed: number; // Number of users that signed up but never paid + 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 } ``` diff --git a/lnvps_api/src/api/referral.rs b/lnvps_api/src/api/referral.rs index 14150dde..ce1a1ecf 100644 --- a/lnvps_api/src/api/referral.rs +++ b/lnvps_api/src/api/referral.rs @@ -5,8 +5,9 @@ use axum::extract::State; use axum::routing::{get, patch, post}; use chrono::Utc; use lnvps_api_common::{ApiData, ApiError, ApiResult, Nip98Auth}; -use lnvps_db::{Referral, ReferralSummary}; +use lnvps_db::{Referral, ReferralCostUsage, ReferralPayout}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; pub fn router() -> Router { Router::new().route( @@ -41,29 +42,78 @@ impl From for ApiReferral { } } -/// Response type combining referral info and summary stats +/// 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, + pub is_paid: bool, + pub invoice: Option, +} + +impl From 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, - /// Total amount pending payout (earned but not yet paid) - pub pending_amount: u64, - /// Total lifetime paid amount - pub paid_amount: u64, - /// Number of referrals that resulted in a paid subscription + /// Per-currency breakdown of amounts earned from referrals + pub earned: Vec, + /// Complete payout history (most recent first) + pub payouts: Vec, + /// Number of referred VMs that made at least one payment pub referrals_success: u64, - /// Number of referrals that never paid + /// Number of referred VMs that never made a payment pub referrals_failed: u64, } impl ApiReferralState { - pub fn from_referral_and_summary(referral: Referral, summary: ReferralSummary) -> Self { + fn build( + referral: Referral, + usage: Vec, + payouts: Vec, + referrals_failed: u64, + ) -> Self { + // Aggregate earned amounts per currency + let mut by_currency: HashMap = HashMap::new(); + for u in &usage { + *by_currency.entry(u.currency.clone()).or_insert(0) += u.amount; + } + let mut earned: Vec = 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(), - pending_amount: summary.pending_amount, - paid_amount: summary.paid_amount, - referrals_success: summary.referrals_success, - referrals_failed: summary.referrals_failed, + earned, + payouts: payouts.into_iter().map(Into::into).collect(), } } } @@ -102,7 +152,7 @@ fn generate_referral_code() -> String { .collect() } -/// Get current referral state (code, stats, payout info) +/// Get current referral state (code, per-currency earnings, payout history, counts) async fn v1_get_referral( auth: Nip98Auth, State(this): State, @@ -116,9 +166,13 @@ async fn v1_get_referral( .await .map_err(|_| ApiError::new("Not enrolled in referral program"))?; - let summary = this.db.get_referral_summary(referral.id).await?; + 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::from_referral_and_summary(referral, summary)) + ApiData::ok(ApiReferralState::build(referral, usage, payouts, referrals_failed)) } /// Sign up for the referral program diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index abdb4862..4431aee1 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -6,7 +6,7 @@ use lnvps_db::{ AccessPolicy, AvailableIpSpace, Company, CpuArch, CpuMfg, DbResult, DiskInterface, DiskType, IpRange, IpRangeAllocationMode, IpRangeSubscription, IpSpacePricing, LNVpsDbBase, NostrDomain, NostrDomainHandle, OsDistribution, PaymentMethod, PaymentMethodConfig, Referral, - ReferralPayout, ReferralSummary, Router, Subscription, SubscriptionLineItem, + ReferralCostUsage, ReferralPayout, Router, Subscription, SubscriptionLineItem, SubscriptionPayment, SubscriptionPaymentWithCompany, User, UserSshKey, Vm, VmCostPlan, VmCostPlanIntervalType, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHistory, VmHost, VmHostDisk, VmHostKind, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, @@ -1545,61 +1545,40 @@ impl LNVpsDbBase for MockDb { .collect()) } - async fn get_referral_summary(&self, referral_id: u64) -> DbResult { - let referrals = self.referrals.lock().await; - let referral = referrals - .get(&referral_id) - .cloned() - .ok_or_else(|| anyhow!("Referral not found: {}", referral_id))?; - drop(referrals); - - // Compute total_amount as sum of first paid payment per VM with this ref code + async fn list_referral_usage(&self, code: &str) -> DbResult> { let vms = self.vms.lock().await; let payments = self.payments.lock().await; - let total_amount: u64 = vms - .values() - .filter(|v| v.ref_code.as_deref() == Some(&referral.code)) - .filter_map(|v| { - // Get the first paid payment for this VM - let mut vm_payments: Vec<&VmPayment> = payments - .iter() - .filter(|p| p.vm_id == v.id && p.is_paid) - .collect(); - vm_payments.sort_by_key(|p| p.created); - vm_payments.first().map(|p| p.amount) - }) - .sum(); + let mut result = Vec::new(); + for vm in vms.values().filter(|v| v.ref_code.as_deref() == Some(code)) { + let mut vm_payments: Vec<&VmPayment> = payments + .iter() + .filter(|p| p.vm_id == vm.id && p.is_paid) + .collect(); + vm_payments.sort_by_key(|p| p.created); + if let Some(first) = vm_payments.first() { + result.push(ReferralCostUsage { + vm_id: vm.id, + ref_code: code.to_string(), + created: first.created, + amount: first.amount, + currency: first.currency.clone(), + rate: first.rate, + base_currency: "EUR".to_string(), + }); + } + } + result.sort_by(|a, b| b.created.cmp(&a.created)); + Ok(result) + } - let referrals_success = vms - .values() - .filter(|v| v.ref_code.as_deref() == Some(&referral.code)) - .filter(|v| payments.iter().any(|p| p.vm_id == v.id && p.is_paid)) - .count() as u64; - let referrals_failed = vms + async fn count_failed_referrals(&self, code: &str) -> DbResult { + let vms = self.vms.lock().await; + let payments = self.payments.lock().await; + Ok(vms .values() - .filter(|v| v.ref_code.as_deref() == Some(&referral.code)) + .filter(|v| v.ref_code.as_deref() == Some(code)) .filter(|v| !payments.iter().any(|p| p.vm_id == v.id && p.is_paid)) - .count() as u64; - drop(payments); - drop(vms); - - let payouts = self.referral_payouts.lock().await; - let paid_amount: u64 = payouts - .iter() - .filter(|p| p.referral_id == referral_id && p.is_paid) - .map(|p| p.amount) - .sum(); - drop(payouts); - - let pending_amount = total_amount.saturating_sub(paid_amount); - - Ok(ReferralSummary { - code: referral.code, - pending_amount, - paid_amount, - referrals_success, - referrals_failed, - }) + .count() as u64) } } diff --git a/lnvps_db/src/lib.rs b/lnvps_db/src/lib.rs index 77735a06..8d2a0d44 100644 --- a/lnvps_db/src/lib.rs +++ b/lnvps_db/src/lib.rs @@ -532,8 +532,12 @@ pub trait LNVpsDbBase: Send + Sync { /// List all payout records for a referral async fn list_referral_payouts(&self, referral_id: u64) -> DbResult>; - /// Get summary stats for a referral (single query) - async fn get_referral_summary(&self, referral_id: u64) -> DbResult; + /// List the first paid VM payment per VM that used this referral code. + /// This is the basis for computing earned amounts (per currency). + async fn list_referral_usage(&self, code: &str) -> DbResult>; + + /// Count VMs that used this referral code but have never made a paid payment. + async fn count_failed_referrals(&self, code: &str) -> DbResult; } /// Super trait that combines all database functionality based on enabled features diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index a77139b3..0815c95c 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -935,20 +935,6 @@ pub struct ReferralPayout { pub pre_image: Option>, } -#[derive(FromRow, Clone, Debug, Default)] -pub struct ReferralSummary { - /// The referral code - pub code: String, - /// Total pending payout amount (created but not yet paid) - pub pending_amount: u64, - /// Total lifetime paid amount - pub paid_amount: u64, - /// Number of VMs that used this referral code and made at least one payment - pub referrals_success: u64, - /// Number of VMs that used this referral code but never made a payment - pub referrals_failed: u64, -} - #[derive(FromRow, Clone, Debug)] pub struct ReferralCostUsage { pub vm_id: u64, diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index 1e1857fa..c7bf6f9a 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -1,11 +1,10 @@ use crate::{ AccessPolicy, AvailableIpSpace, Company, DbError, DbResult, IpRange, IpRangeSubscription, IpSpacePricing, LNVpsDbBase, PaymentMethod, PaymentMethodConfig, PaymentType, Referral, - ReferralCostUsage, ReferralPayout, ReferralSummary, RegionStats, Router, Subscription, - SubscriptionLineItem, SubscriptionPayment, SubscriptionPaymentWithCompany, User, UserSshKey, - Vm, VmCostPlan, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHistory, VmHost, - VmHostDisk, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmPaymentWithCompany, - VmTemplate, + ReferralCostUsage, ReferralPayout, RegionStats, Router, Subscription, SubscriptionLineItem, + SubscriptionPayment, SubscriptionPaymentWithCompany, User, UserSshKey, Vm, VmCostPlan, + VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHistory, VmHost, VmHostDisk, + VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmPaymentWithCompany, VmTemplate, }; #[cfg(feature = "admin")] use crate::{AdminDb, AdminRole, AdminRoleAssignment, AdminVmHost}; @@ -1865,53 +1864,46 @@ impl LNVpsDbBase for LNVpsDbMysql { ) } - async fn get_referral_summary(&self, referral_id: u64) -> DbResult { + async fn list_referral_usage(&self, code: &str) -> DbResult> { Ok(sqlx::query_as( - "SELECT - r.code, - CAST(GREATEST(0, COALESCE(first_payments.total_amount, 0) - COALESCE(paid_payouts.paid_amount, 0)) AS UNSIGNED) AS pending_amount, - CAST(COALESCE(paid_payouts.paid_amount, 0) AS UNSIGNED) AS paid_amount, - CAST(COALESCE(( - SELECT COUNT(DISTINCT v.id) - FROM vm v - WHERE v.ref_code = r.code - AND EXISTS ( - SELECT 1 FROM vm_payment vp WHERE vp.vm_id = v.id AND vp.is_paid = 1 - ) - ), 0) AS UNSIGNED) AS referrals_success, - CAST(COALESCE(( - SELECT COUNT(DISTINCT v.id) - FROM vm v - WHERE v.ref_code = r.code - AND NOT EXISTS ( - SELECT 1 FROM vm_payment vp WHERE vp.vm_id = v.id AND vp.is_paid = 1 - ) - ), 0) AS UNSIGNED) AS referrals_failed - FROM referral r - LEFT JOIN ( - SELECT SUM(vp.amount) AS total_amount - FROM vm v - JOIN ( - SELECT vm_id, amount, - ROW_NUMBER() OVER (PARTITION BY vm_id ORDER BY created ASC) AS rn - FROM vm_payment - WHERE is_paid = 1 - ) vp ON v.id = vp.vm_id AND vp.rn = 1 - WHERE v.ref_code = (SELECT code FROM referral WHERE id = ?) - ) first_payments ON TRUE - LEFT JOIN ( - SELECT SUM(amount) AS paid_amount - FROM referral_payout - WHERE referral_id = ? AND is_paid = 1 - ) paid_payouts ON TRUE - WHERE r.id = ?", - ) - .bind(referral_id) - .bind(referral_id) - .bind(referral_id) - .fetch_one(&self.db) + "SELECT v.id as vm_id, + v.ref_code, + vp.created, + vp.amount, + vp.currency, + vp.rate, + c.base_currency + FROM vm v + JOIN ( + SELECT vm_id, currency, amount, created, rate, + ROW_NUMBER() OVER (PARTITION BY vm_id ORDER BY created ASC) AS rn + FROM vm_payment + WHERE is_paid = 1 + ) vp ON v.id = vp.vm_id AND vp.rn = 1 + JOIN vm_host vh ON v.host_id = vh.id + JOIN vm_host_region vhr ON vh.region_id = vhr.id + JOIN company c ON vhr.company_id = c.id + WHERE v.ref_code = ? + ORDER BY vp.created DESC", + ) + .bind(code) + .fetch_all(&self.db) .await?) } + + async fn count_failed_referrals(&self, code: &str) -> DbResult { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM vm v + WHERE v.ref_code = ? + AND NOT EXISTS ( + SELECT 1 FROM vm_payment vp WHERE vp.vm_id = v.id AND vp.is_paid = 1 + )", + ) + .bind(code) + .fetch_one(&self.db) + .await?; + Ok(count as u64) + } } #[cfg(feature = "nostr-domain")] From 3d59ef1696e86fd747b70819d64bf75beed8f11a Mon Sep 17 00:00:00 2001 From: Kieran Date: Fri, 20 Feb 2026 16:29:52 +0000 Subject: [PATCH 7/9] Fix API changelog, unused imports, and base32->base63 doc comment - Add API changelog entry for referral program endpoints - Fix unused imports (patch, post) in referral.rs - Reorganize imports per code style guidelines - Fix documentation comment: base32 -> base63 for referral codes --- API_CHANGELOG.md | 5 +++++ lnvps_api/src/api/referral.rs | 13 +++++++------ lnvps_db/src/model.rs | 2 +- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index 60d65696..ab7e2bb3 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -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 diff --git a/lnvps_api/src/api/referral.rs b/lnvps_api/src/api/referral.rs index ce1a1ecf..c2015ee1 100644 --- a/lnvps_api/src/api/referral.rs +++ b/lnvps_api/src/api/referral.rs @@ -1,14 +1,15 @@ -use crate::api::RouterState; -use axum::Json; -use axum::Router; use axum::extract::State; -use axum::routing::{get, patch, post}; +use axum::routing::get; +use axum::{Json, Router}; use chrono::Utc; -use lnvps_api_common::{ApiData, ApiError, ApiResult, Nip98Auth}; -use lnvps_db::{Referral, ReferralCostUsage, ReferralPayout}; 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 { Router::new().route( "/api/v1/referral", diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index 0815c95c..fbebcddf 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -905,7 +905,7 @@ pub struct Referral { pub id: u64, /// The user that owns this referral pub user_id: u64, - /// The auto-generated referral code (base32, 8 characters) + /// The auto-generated referral code (base63, 8 characters) pub code: String, /// Lightning address for automatic payouts pub lightning_address: Option, From 1a4653c09d3f22961ed03fa77496c73786aafe86 Mon Sep 17 00:00:00 2001 From: Kieran Date: Fri, 20 Feb 2026 16:38:04 +0000 Subject: [PATCH 8/9] Fix migration timestamp conflict: 20260219 -> 20260220 Another migration (cpu_type) already uses timestamp 20260219000000. --- ...0_referral_program.sql => 20260220000000_referral_program.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename lnvps_db/migrations/{20260219000000_referral_program.sql => 20260220000000_referral_program.sql} (100%) diff --git a/lnvps_db/migrations/20260219000000_referral_program.sql b/lnvps_db/migrations/20260220000000_referral_program.sql similarity index 100% rename from lnvps_db/migrations/20260219000000_referral_program.sql rename to lnvps_db/migrations/20260220000000_referral_program.sql From 964f2b38b02655b23925ec2cdc562f59942b2e02 Mon Sep 17 00:00:00 2001 From: Kieran Date: Fri, 20 Feb 2026 16:38:53 +0000 Subject: [PATCH 9/9] Fix foreign key constraint: use INTEGER UNSIGNED to match users.id --- lnvps_db/migrations/20260220000000_referral_program.sql | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lnvps_db/migrations/20260220000000_referral_program.sql b/lnvps_db/migrations/20260220000000_referral_program.sql index 7345cfd3..e66f0953 100644 --- a/lnvps_db/migrations/20260220000000_referral_program.sql +++ b/lnvps_db/migrations/20260220000000_referral_program.sql @@ -1,7 +1,7 @@ -- Referral program tables CREATE TABLE referral ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - user_id BIGINT UNSIGNED NOT NULL, + id INTEGER UNSIGNED NOT NULL AUTO_INCREMENT, + user_id INTEGER UNSIGNED NOT NULL, code VARCHAR(20) NOT NULL, lightning_address VARCHAR(200), use_nwc BOOLEAN NOT NULL DEFAULT FALSE, @@ -13,8 +13,8 @@ CREATE TABLE referral ( ); CREATE TABLE referral_payout ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - referral_id BIGINT UNSIGNED NOT NULL, + id INTEGER UNSIGNED NOT NULL AUTO_INCREMENT, + referral_id INTEGER UNSIGNED NOT NULL, amount BIGINT UNSIGNED NOT NULL, currency VARCHAR(5) NOT NULL, created DATETIME NOT NULL DEFAULT NOW(),