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/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index d0ab8183..9ff9286b 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -621,6 +621,71 @@ 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 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 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..c2015ee1 --- /dev/null +++ b/lnvps_api/src/api/referral.rs @@ -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 { + 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, + } + } +} + +/// 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, + /// 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 referred VMs that never made a payment + pub referrals_failed: u64, +} + +impl ApiReferralState { + 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(), + 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, + /// 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 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, +) -> 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 (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, + 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 = "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 = (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..4431aee1 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, + ReferralCostUsage, ReferralPayout, 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,105 @@ 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; + p.pre_image = payout.pre_image.clone(); + } + 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 list_referral_usage(&self, code: &str) -> DbResult> { + let vms = self.vms.lock().await; + let payments = self.payments.lock().await; + 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) + } + + 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(code)) + .filter(|v| !payments.iter().any(|p| p.vm_id == v.id && p.is_paid)) + .count() as u64) + } } pub struct MockExchangeRate { diff --git a/lnvps_db/migrations/20260220000000_referral_program.sql b/lnvps_db/migrations/20260220000000_referral_program.sql new file mode 100644 index 00000000..e66f0953 --- /dev/null +++ b/lnvps_db/migrations/20260220000000_referral_program.sql @@ -0,0 +1,26 @@ +-- Referral program tables +CREATE TABLE referral ( + 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, + 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 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(), + is_paid BOOLEAN NOT NULL DEFAULT FALSE, + invoice VARCHAR(2048), + pre_image BINARY(32), + 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..8d2a0d44 100644 --- a/lnvps_db/src/lib.rs +++ b/lnvps_db/src/lib.rs @@ -506,6 +506,38 @@ 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>; + + /// 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 5a15c3e1..fbebcddf 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -899,6 +899,42 @@ 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 (base63, 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, + /// Lightning invoice for this payout + pub invoice: Option, + /// Preimage revealed when the invoice was paid (32 bytes, SHA256) + pub pre_image: Option>, +} + #[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..c7bf6f9a 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -1,7 +1,7 @@ use crate::{ AccessPolicy, AvailableIpSpace, Company, DbError, DbResult, IpRange, IpRangeSubscription, - IpSpacePricing, LNVpsDbBase, PaymentMethod, PaymentMethodConfig, PaymentType, - ReferralCostUsage, RegionStats, Router, Subscription, SubscriptionLineItem, + IpSpacePricing, LNVpsDbBase, PaymentMethod, PaymentMethodConfig, PaymentType, Referral, + ReferralCostUsage, ReferralPayout, RegionStats, Router, Subscription, SubscriptionLineItem, SubscriptionPayment, SubscriptionPaymentWithCompany, User, UserSshKey, Vm, VmCostPlan, VmCustomPricing, VmCustomPricingDisk, VmCustomTemplate, VmHistory, VmHost, VmHostDisk, VmHostRegion, VmIpAssignment, VmOsImage, VmPayment, VmPaymentWithCompany, VmTemplate, @@ -1792,6 +1792,118 @@ 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, 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 = ?, pre_image = ? WHERE id = ?") + .bind(payout.is_paid) + .bind(&payout.pre_image) + .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 list_referral_usage(&self, code: &str) -> DbResult> { + Ok(sqlx::query_as( + "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")]