diff --git a/ADMIN_API_ENDPOINTS.md b/ADMIN_API_ENDPOINTS.md index 7ddf6dd8..61060048 100644 --- a/ADMIN_API_ENDPOINTS.md +++ b/ADMIN_API_ENDPOINTS.md @@ -1962,13 +1962,16 @@ Body: // Optional - Postal/ZIP code "phone": "string | null", // Optional - Phone number - "email": "string | null" + "email": "string | null", // Optional - Contact email + "referral_rate": 0.0 + // Optional - Default referral commission (whole %, default 0) applied to a + // referred VM's first payment when the referrer has no per-referrer override } ``` The `base_currency` field is validated against the supported Currency enum values. Invalid currency codes will be -rejected with an error message listing valid currencies. +rejected with an error message listing valid currencies. `referral_rate` must be >= 0. #### Update Company @@ -2002,12 +2005,16 @@ Body (all optional): // Postal/ZIP code "phone": "string | null", // Phone number - "email": "string | null" + "email": "string | null", // Contact email + "referral_rate": 0.0 + // Default referral commission (whole %); must be >= 0 } ``` -The `base_currency` field is validated against the supported Currency enum values. +The `base_currency` field is validated against the supported Currency enum values. `referral_rate` (when provided) must be >= 0. + +GET/list company responses include `referral_rate` (the company's default referral commission %). Note: Empty strings are treated as null values (clearing the field). @@ -2997,6 +3004,151 @@ All messages are structured with a `type` field for consistent handling: } ``` +### Referral Program Management + +All endpoints require the `referral` resource permissions. Responses never expose +NWC connection secrets (the NWC connection lives on the user's payment method). + +#### List Referrals + +``` +GET /api/admin/v1/referrals +``` + +Required Permission: `referral::view` + +Query Parameters: + +- `limit`: number (optional, default 50, max 100) +- `offset`: number (optional, default 0) +- `search`: string (optional) - substring match on referral code, or a 64-char hex user pubkey + +Returns a paginated list of `AdminReferralInfo`: + +```json +{ + "data": [ + { + "id": 12, + "user_id": 34, + "user_pubkey": "", + "code": "ALPHA123", + "lightning_address": "user@domain.com", + "mode": "lightning_address", + "referral_rate": 12.5, + "created": "2026-07-18T10:00:00Z" + } + ], + "total": 1, + "limit": 50, + "offset": 0 +} +``` + +`referral_rate` is the per-referrer commission override (whole %); `null` means the referred VM's `company.referral_rate` default applies. + +#### Get Referral Detail + +``` +GET /api/admin/v1/referrals/{id} +``` + +Required Permission: `referral::view` + +Returns the referral plus per-currency earned commission, payout history and counts: + +```json +{ + "data": { + "id": 12, + "user_id": 34, + "user_pubkey": "", + "code": "ALPHA123", + "lightning_address": "user@domain.com", + "mode": "lightning_address", + "referral_rate": 12.5, + "created": "2026-07-18T10:00:00Z", + "earned": [ { "currency": "BTC", "amount": 5000 } ], + "payouts": [ + { "id": 1, "amount": 5000, "currency": "BTC", "created": "2026-07-18T11:00:00Z", "is_paid": true, "invoice": "lnbc...", "pre_image": "" } + ], + "referrals_success": 3, + "referrals_failed": 1 + } +} +``` + +`earned` is commission (`first payment * effective_rate%`) aggregated per currency. + +#### Update Referral (commission override) + +``` +PATCH /api/admin/v1/referrals/{id} +``` + +Required Permission: `referral::update` + +Body: + +```json +{ + "referral_rate": 12.5 + // Set (number, >= 0), clear to company default (null), or omit to leave unchanged +} +``` + +#### List Referral Payouts + +``` +GET /api/admin/v1/referrals/{id}/payouts +``` + +Required Permission: `referral::view` + +Returns an array of payout records (`AdminReferralPayoutInfo`), most recent first. + +#### Create Referral Payout + +``` +POST /api/admin/v1/referrals/{id}/payouts +``` + +Required Permission: `referral::create` + +Creates a manual payout record (e.g. reconciling an out-of-band payment). + +```json +{ + "amount": 5000, + // Required - smallest currency unit, > 0 + "currency": "BTC", + // Required + "invoice": "lnbc...", + // Optional - associated Lightning invoice + "is_paid": false + // Optional - mark already paid (default false) +} +``` + +#### Update / Reconcile Referral Payout + +``` +PATCH /api/admin/v1/referrals/{id}/payouts/{payout_id} +``` + +Required Permission: `referral::update` + +```json +{ + "is_paid": true, + // Optional - mark paid/unpaid + "invoice": "lnbc...", + // Optional - set (string) or clear (null) the invoice + "pre_image": "" + // Optional - set (hex, 32 bytes) or clear (null) the payment preimage +} +``` + ### Reports #### Time Series Report @@ -3079,13 +3231,17 @@ Response: "amount": 125000, "currency": "USD", "rate": 1.0, - "base_currency": "USD" + "base_currency": "USD", + "effective_rate": 10.0, + "commission": 12500 } ] } } ``` +`amount` is the referred VM's first payment; `effective_rate` is the applied commission % (the referrer's per-referrer override if set, else the referred VM's `company.referral_rate`); `commission = amount * effective_rate%` (floored), in `currency` smallest units. + #### Profit/Loss Report ``` diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index dc40b285..027a0846 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -8,6 +8,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- **2026-07-18** - Referral program: leave + per-VM usage (user API) + - `DELETE /api/v1/referral` lets a referrer leave the program. Blocked (409) while a payout is pending, or when paid payout history exists (retained for accounting). + - `GET /api/v1/referral/usage` returns a per-referred-VM breakdown: `vm_id`, `created`, `amount` (the VM's first payment), `currency`, `effective_rate`, and `commission`. + +- **2026-07-18** - Automated referral commission payouts + - A new opt-in worker job pays referrers their accrued **BTC** commission over Lightning. For each referrer whose owed commission (earned minus already paid/reserved) clears a configurable threshold, it reserves a payout, fetches a BOLT11 invoice (LNURL-pay for a `lightning_address` referrer, or NWC `make_invoice` for an `nwc` referrer), pays it from the node, and records the preimage. Non-BTC (fiat) commission is not auto-paid and is left for manual admin payout. Enabled by adding a `referral` config section (`min-payout-sats`, default 1000); when omitted, automated payouts are disabled. `GET /api/v1/referral` payout records now include `pre_image` (hex, once settled). + +- **2026-07-18** - Admin referral program management + - New admin endpoints under the `referral` RBAC resource (granted to `super_admin`): `GET /api/admin/v1/referrals` (paginated; `search` by code substring or 64-char hex pubkey), `GET /api/admin/v1/referrals/{id}` (referral + per-currency earned commission + payout history + success/failed counts), `PATCH /api/admin/v1/referrals/{id}` (set/clear the per-referrer commission override), `GET`/`POST /api/admin/v1/referrals/{id}/payouts` (list / create a manual payout record), and `PATCH /api/admin/v1/referrals/{id}/payouts/{payout_id}` (mark paid / set invoice / set preimage for reconciliation). NWC secrets are never exposed. + +- **2026-07-18** - Flexible referral payout mode (replaces `use_nwc`) + - The referral payout method is now an extensible `mode` enum instead of the `use_nwc` boolean. `GET /api/v1/referral` returns `mode` (`lightning_address` | `nwc` | `account_credit`) instead of `use_nwc`. `POST`/`PATCH /api/v1/referral` accept `mode` instead of `use_nwc`: `lightning_address` (default) requires a resolvable `lightning_address`; `nwc` requires a configured NWC connection; `account_credit` is reserved for a future account-balance payout and is currently rejected. Existing enrollments migrate `use_nwc = true` → `nwc`, otherwise `lightning_address`. + +- **2026-07-18** - Referral commission rate (percentage of first payment) + - The referral program now pays a commission = a percentage of each referred VM's **first** payment. The effective rate is per-referrer with a company default: `company.referral_rate` (new, default `0`) applies unless the referrer has an override. Admin company `POST`/`PATCH /api/admin/v1/companies` accept `referral_rate` (whole %, `>= 0`) and GET/list responses expose it. + - `GET /api/v1/referral` now returns `referral_rate` (the per-referrer override, `null` = use company default; read-only — set by admins) and its `earned` amounts are now the commission (`payment * effective_rate%`) rather than the full first payment. + - `GET /api/admin/v1/reports/referral-usage/time-series` rows gain `effective_rate` and `commission`. + - **2026-07-18** - OSS (One-Stop Shop) VAT report - `GET /api/admin/v1/reports/oss` aggregates cross-border EU B2C sales (`tax_treatment = oss_b2c`) by filing period and destination member state for transcription onto an OSS VAT return. Query params: `start_date`, `end_date` (YYYY-MM-DD), optional `company_id` (`0`/omitted = all), and `period` = `quarter` (default, calendar Q1-Q4) | `bimonthly` (two-month buckets B1-B6). Rows are keyed by `(period, company, destination country, VAT rate)` and expressed in each seller company's base currency using the exchange rate frozen on each payment. Only paid payments are counted. Requires `analytics::view`. diff --git a/Cargo.lock b/Cargo.lock index 5b5bf76f..f5fa1979 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2058,6 +2058,22 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -2724,6 +2740,7 @@ dependencies = [ "bitcoin", "cbc", "email_address", + "reqwest 0.12.28", "serde", "serde_json", "url", @@ -4143,10 +4160,12 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log 0.4.32", "mime_guess", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -4158,6 +4177,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", diff --git a/lnvps_api/Cargo.toml b/lnvps_api/Cargo.toml index d2769dc8..a69de6f9 100644 --- a/lnvps_api/Cargo.toml +++ b/lnvps_api/Cargo.toml @@ -79,7 +79,7 @@ rand = "0.9" clap = { version = "4.5", features = ["derive"] } ssh-key = "0.6" lettre = { version = "0.11", features = ["tokio1-native-tls"] } -lnurl-rs = { version = "0.9", default-features = false } +lnurl-rs = { version = "0.9", default-features = false, features = ["async-https-native"] } mustache = "0.9" isocountry = "0.3" hickory-resolver = "0.26" diff --git a/lnvps_api/config.yaml b/lnvps_api/config.yaml index 75d8afb9..94901c11 100644 --- a/lnvps_api/config.yaml +++ b/lnvps_api/config.yaml @@ -5,6 +5,11 @@ lightning: cert: "/home/kieran/.polar/networks/1/volumes/lnd/alice/tls.cert" macaroon: "/home/kieran/.polar/networks/1/volumes/lnd/alice/data/chain/bitcoin/regtest/admin.macaroon" delete-after: 3 +# Automated referral commission payouts (opt-in). Omit this section to disable +# automatic payouts (commission still accrues and can be paid manually by admins). +referral: + # Minimum accrued BTC commission (satoshis) before a payout is attempted. + min-payout-sats: 1000 public-url: "https://api.lnvps.net" read-only: false redis: diff --git a/lnvps_api/src/api/mod.rs b/lnvps_api/src/api/mod.rs index d41ff904..311206c5 100644 --- a/lnvps_api/src/api/mod.rs +++ b/lnvps_api/src/api/mod.rs @@ -96,7 +96,10 @@ pub(crate) async fn resolve_payment_mode( "No NWC payment method configured" ))); } - Ok((PaymentMethod::Lightning, RenewMode::Saved { method_id: None })) + Ok(( + PaymentMethod::Lightning, + RenewMode::Saved { method_id: None }, + )) } Some("saved") => Ok(( PaymentMethod::Revolut, diff --git a/lnvps_api/src/api/referral.rs b/lnvps_api/src/api/referral.rs index 4ad3066f..55f77717 100644 --- a/lnvps_api/src/api/referral.rs +++ b/lnvps_api/src/api/referral.rs @@ -9,17 +9,20 @@ use std::collections::HashMap; use std::str::FromStr; use lnvps_api_common::{ApiData, ApiError, ApiResult, Nip98Auth}; -use lnvps_db::{Referral, ReferralCostUsage, ReferralPayout}; +use lnvps_db::{Referral, ReferralCostUsage, ReferralPayout, ReferralPayoutMode}; 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), - ) + Router::new() + .route( + "/api/v1/referral", + get(v1_get_referral) + .post(v1_signup_referral) + .patch(v1_update_referral) + .delete(v1_delete_referral), + ) + .route("/api/v1/referral/usage", get(v1_get_referral_usage)) } /// Response type for a referral entry @@ -27,10 +30,15 @@ pub fn router() -> Router { pub struct ApiReferral { /// The referral code to share with others pub code: String, - /// Lightning address for automatic payouts + /// Lightning address for automatic payouts (used when `mode` is + /// `lightning_address`) pub lightning_address: Option, - /// Whether to use NWC for payouts - pub use_nwc: bool, + /// Payout method: `lightning_address`, `nwc`, or `account_credit`. + pub mode: String, + /// Per-referrer commission override, as a whole percentage of a referred + /// VM's first payment. `null` means the referred VM's company default rate + /// (`company.referral_rate`) applies instead. + pub referral_rate: Option, /// When the referral was created pub created: chrono::DateTime, } @@ -40,7 +48,8 @@ impl From for ApiReferral { Self { code: r.code, lightning_address: r.lightning_address, - use_nwc: r.use_nwc, + mode: r.mode.to_string(), + referral_rate: r.referral_rate, created: r.created, } } @@ -51,7 +60,9 @@ impl From for ApiReferral { pub struct ApiReferralEarning { /// Currency code pub currency: String, - /// Total earned amount in this currency (sum of first payments per referred VM) + /// Total commission earned in this currency: the sum, over each referred VM's + /// first payment, of `payment * effective_rate%` (the referrer override or + /// the referred VM's company default). pub amount: u64, } @@ -64,6 +75,8 @@ pub struct ApiReferralPayout { pub created: chrono::DateTime, pub is_paid: bool, pub invoice: Option, + /// Payment preimage (hex), present once the payout has settled. + pub pre_image: Option, } impl From for ApiReferralPayout { @@ -75,6 +88,7 @@ impl From for ApiReferralPayout { created: p.created, is_paid: p.is_paid, invoice: p.invoice, + pre_image: p.pre_image.map(hex::encode), } } } @@ -101,10 +115,10 @@ impl ApiReferralState { payouts: Vec, referrals_failed: u64, ) -> Self { - // Aggregate earned amounts per currency + // Aggregate earned commission per currency (payment * effective_rate%). let mut by_currency: HashMap = HashMap::new(); for u in &usage { - *by_currency.entry(u.currency.clone()).or_insert(0) += u.amount; + *by_currency.entry(u.currency.clone()).or_insert(0) += u.commission(); } let mut earned: Vec = by_currency .into_iter() @@ -122,14 +136,30 @@ impl ApiReferralState { } } +/// A single referred VM and the commission earned from its first payment. +#[derive(Serialize)] +pub struct ApiReferralUsage { + /// The referred VM's id. + pub vm_id: u64, + /// When the first paid payment was made. + pub created: chrono::DateTime, + /// The referred VM's first payment amount (smallest currency unit). + pub amount: u64, + /// Currency of the payment / commission. + pub currency: String, + /// Effective commission rate applied (whole %). + pub effective_rate: f32, + /// Commission earned = amount * effective_rate% (smallest currency unit). + pub commission: u64, +} + /// Request to sign up for the referral program #[derive(Deserialize)] pub struct ApiReferralSignupRequest { - /// Lightning address for payouts (optional) + /// Lightning address for payouts (required when `mode` is `lightning_address`) pub lightning_address: Option, - /// Use NWC connection for payouts - #[serde(default)] - pub use_nwc: bool, + /// Payout method: `lightning_address` (default) or `nwc`. + pub mode: Option, } /// Request to update referral payout options @@ -142,8 +172,26 @@ pub struct ApiReferralPatchRequest { deserialize_with = "lnvps_api_common::deserialize_nullable_option" )] pub lightning_address: Option>, - /// Use NWC connection for payouts - pub use_nwc: Option, + /// Payout method: `lightning_address`, `nwc`, or `account_credit`. + pub mode: Option, +} + +/// Resolve and validate a requested payout `mode`, defaulting when omitted. +/// +/// `account_credit` is a defined-but-unimplemented mode and is rejected until +/// the account-balance system exists. +fn parse_payout_mode(mode: Option<&str>) -> Result, ApiError> { + let Some(s) = mode else { + return Ok(None); + }; + let parsed = ReferralPayoutMode::from_str(s) + .map_err(|_| ApiError::new("Invalid payout mode. Use 'lightning_address' or 'nwc'"))?; + if parsed == ReferralPayoutMode::AccountCredit { + return Err(ApiError::new( + "Account credit payouts are not yet available", + )); + } + Ok(Some(parsed)) } /// Validate a lightning address by parsing its format and resolving the LNURL pay endpoint @@ -227,21 +275,26 @@ async fn v1_signup_referral( return Err(ApiError::conflict("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", - ); - } - - // Validate lightning address - if let Some(ref addr) = req.lightning_address { - validate_lightning_address(addr).await?; - } - - // If use_nwc is requested, ensure user has an NWC payment method configured - if req.use_nwc && !user_has_nwc(&this, uid).await { - return ApiData::err("NWC connection is not configured on your account"); + // Resolve the payout mode, defaulting to lightning_address when omitted. + let mode = + parse_payout_mode(req.mode.as_deref())?.unwrap_or(ReferralPayoutMode::LightningAddress); + + // Validate the payout details required by the chosen mode. + match mode { + ReferralPayoutMode::LightningAddress => match req.lightning_address.as_deref() { + Some(addr) if !addr.trim().is_empty() => validate_lightning_address(addr).await?, + _ => { + return ApiData::err( + "lightning_address is required when mode is 'lightning_address'", + ); + } + }, + ReferralPayoutMode::Nwc => { + if !user_has_nwc(&this, uid).await { + return ApiData::err("NWC connection is not configured on your account"); + } + } + ReferralPayoutMode::AccountCredit => unreachable!("rejected by parse_payout_mode"), } let code = generate_referral_code(); @@ -250,7 +303,10 @@ async fn v1_signup_referral( user_id: uid, code, lightning_address: req.lightning_address, - use_nwc: req.use_nwc, + mode, + // Per-referrer commission override is admin-controlled; new enrollments + // default to the referred VM's company rate (None = use company default). + referral_rate: None, created: Utc::now(), }; @@ -281,18 +337,84 @@ async fn v1_update_referral( } referral.lightning_address = addr.clone(); } - if let Some(use_nwc) = req.use_nwc { - if use_nwc && !user_has_nwc(&this, uid).await { + if let Some(mode) = parse_payout_mode(req.mode.as_deref())? { + if mode == ReferralPayoutMode::Nwc && !user_has_nwc(&this, uid).await { return ApiData::err("NWC connection is not configured on your account"); } - referral.use_nwc = use_nwc; + referral.mode = mode; } + // Note: we intentionally do NOT require the resulting config to be immediately + // payable (e.g. a lightning_address-mode referral may temporarily have no + // address). The payout worker skips referrers whose method can't produce an + // invoice, so an incomplete config simply defers payouts rather than losing + // them. Signup still requires a valid method up-front. this.db.update_referral(&referral).await?; ApiData::ok(referral.into()) } +/// Leave the referral program. +/// +/// Blocked while any payout records exist: a **pending** payout must settle +/// first, and paid payout history is retained for accounting (so a referrer who +/// has ever been paid cannot delete their enrollment). +async fn v1_delete_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::not_found("Not enrolled in referral program"))?; + + let payouts = this.db.list_referral_payouts(referral.id).await?; + if payouts.iter().any(|p| !p.is_paid) { + return Err(ApiError::conflict( + "Cannot leave the referral program while a payout is pending", + )); + } + if !payouts.is_empty() { + return Err(ApiError::conflict( + "Cannot leave the referral program because payout history exists", + )); + } + + this.db.delete_referral(referral.id).await?; + ApiData::ok(()) +} + +/// Per-referred-VM breakdown: each referred VM's first payment and the +/// commission earned from it. +async fn v1_get_referral_usage( + 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::not_found("Not enrolled in referral program"))?; + + let usage = this.db.list_referral_usage(&referral.code).await?; + let out: Vec = usage + .into_iter() + .map(|u| ApiReferralUsage { + vm_id: u.vm_id, + created: u.created, + amount: u.amount, + commission: u.commission(), + effective_rate: u.effective_rate, + currency: u.currency, + }) + .collect(); + ApiData::ok(out) +} + #[cfg(test)] mod tests { use super::*; @@ -303,6 +425,24 @@ mod tests { assert_eq!(code.len(), 8); } + #[test] + fn test_parse_payout_mode() { + // Omitted -> None (caller applies its own default / keeps existing) + assert!(matches!(parse_payout_mode(None), Ok(None))); + assert!(matches!( + parse_payout_mode(Some("lightning_address")), + Ok(Some(ReferralPayoutMode::LightningAddress)) + )); + assert!(matches!( + parse_payout_mode(Some("nwc")), + Ok(Some(ReferralPayoutMode::Nwc)) + )); + // account_credit is defined but not yet available -> error + assert!(matches!(parse_payout_mode(Some("account_credit")), Err(_))); + // unknown -> error + assert!(matches!(parse_payout_mode(Some("paypal")), Err(_))); + } + #[test] fn test_generate_referral_code_alphabet() { const VALID: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_"; diff --git a/lnvps_api/src/bin/api.rs b/lnvps_api/src/bin/api.rs index a755c3a2..7881388a 100644 --- a/lnvps_api/src/bin/api.rs +++ b/lnvps_api/src/bin/api.rs @@ -196,6 +196,7 @@ async fn main() -> Result<(), Error> { db.clone(), work_commander.clone(), sub_handler.clone(), + node.clone(), &settings, status.clone(), nostr_client.clone(), @@ -211,6 +212,13 @@ async fn main() -> Result<(), Error> { tasks.push(worker.spawn_job_interval(WorkJob::CheckSubscriptions, Duration::from_secs(30))); // Refresh cached router tunnel/BGP session/route state + traffic every 60s tasks.push(worker.spawn_job_interval(WorkJob::SyncRouterState, Duration::from_secs(60))); + // Automated referral payouts are opt-in (config-gated); run hourly. + if settings.referral.is_some() { + tasks.push( + worker + .spawn_job_interval(WorkJob::ProcessReferralPayouts, Duration::from_secs(3600)), + ); + } tasks.push(worker.spawn_handler_loop()); // check all nostr domains every 10 minutes for CNAME entries (enable/disable as needed) diff --git a/lnvps_api/src/lib.rs b/lnvps_api/src/lib.rs index 041d56d6..5efe755b 100644 --- a/lnvps_api/src/lib.rs +++ b/lnvps_api/src/lib.rs @@ -5,6 +5,7 @@ pub mod notifications; pub mod payment_factory; pub mod payments; pub mod provisioner; +pub mod referral; pub mod router; pub mod settings; #[cfg(any(feature = "proxmox", feature = "linux-ssh"))] diff --git a/lnvps_api/src/referral/mod.rs b/lnvps_api/src/referral/mod.rs new file mode 100644 index 00000000..23204746 --- /dev/null +++ b/lnvps_api/src/referral/mod.rs @@ -0,0 +1,284 @@ +//! Automated referral commission payouts. +//! +//! Referrers accrue commission (a percentage of each referred VM's first +//! payment; see the pricing/DB layer). This module turns that accrued **BTC** +//! commission into outgoing Lightning payments, independently of the +//! subscription/billing machinery. +//! +//! Non-BTC (fiat) commission is never auto-paid here — Lightning settles in +//! sats — and is left to accrue for manual admin payout. Automated payouts are +//! opt-in: when no minimum threshold is configured they are disabled entirely. + +use anyhow::{Context, Result, anyhow, bail}; +use chrono::Utc; +use lnvps_api_common::{WorkCommander, WorkJob}; +use lnvps_db::{LNVpsDb, Referral, ReferralPayout, ReferralPayoutMode}; +use log::{debug, info, warn}; +use payments_rs::lightning::{LightningNode, PayInvoiceRequest}; +use std::str::FromStr; +use std::sync::Arc; + +/// Compute the payable BTC referral commission (in millisats) from the earned +/// and already-reserved/paid amounts and the minimum threshold. +/// +/// Returns `None` when the outstanding balance is below `min_msat` or rounds to +/// zero whole sats. Lightning settles whole sats, so any sub-sat remainder is +/// dropped and stays owed for a later payout. +fn payable_referral_msat(earned_msat: u64, existing_msat: u64, min_msat: u64) -> Option { + let owed = earned_msat.saturating_sub(existing_msat); + if owed < min_msat { + return None; + } + let pay_msat = (owed / 1000) * 1000; + if pay_msat == 0 { None } else { Some(pay_msat) } +} + +/// Pays referrers their accrued BTC commission over Lightning. +#[derive(Clone)] +pub struct ReferralPayoutHandler { + db: Arc, + node: Arc, + tx: Arc, + /// Minimum accrued BTC commission (millisats) before a payout is attempted. + /// `None` disables automated payouts. + min_payout_msat: Option, +} + +impl ReferralPayoutHandler { + /// Create a handler. `min_payout_sats` of `None` disables automated payouts; + /// commission still accrues and can be paid manually by admins. + pub fn new( + db: Arc, + node: Arc, + tx: Arc, + min_payout_sats: Option, + ) -> Self { + Self { + db, + node, + tx, + min_payout_msat: min_payout_sats.map(|s| s.saturating_mul(1000)), + } + } + + /// Process automated payouts for every enrolled referrer. Per-referrer + /// failures are logged and do not abort the batch. + pub async fn process_payouts(&self) -> Result<()> { + let Some(min_msat) = self.min_payout_msat else { + return Ok(()); + }; + let referrals = self.db.list_all_referrals().await?; + debug!( + "Processing referral payouts for {} referrers (min {} msat)", + referrals.len(), + min_msat + ); + for referral in referrals { + if let Err(e) = self.process_one(&referral, min_msat).await { + warn!("Referral payout failed for code {}: {}", referral.code, e); + } + } + Ok(()) + } + + /// Accrue and pay a single referrer's owed BTC commission, if it clears the + /// threshold. Reserves the payout before paying so a crash or concurrent run + /// cannot double-pay; the reservation is deleted if the payment fails. + async fn process_one(&self, referral: &Referral, min_msat: u64) -> Result<()> { + // Earned BTC commission (millisats) across all first payments. + let usage = self.db.list_referral_usage(&referral.code).await?; + let earned_msat: u64 = usage + .iter() + .filter(|u| u.currency.eq_ignore_ascii_case("BTC")) + .map(|u| u.commission()) + .sum(); + + // Subtract every existing BTC payout record (paid AND reserved) so an + // in-flight reservation is never paid twice. + let existing: u64 = self + .db + .list_referral_payouts(referral.id) + .await? + .iter() + .filter(|p| p.currency.eq_ignore_ascii_case("BTC")) + .map(|p| p.amount) + .sum(); + + let Some(pay_msat) = payable_referral_msat(earned_msat, existing, min_msat) else { + return Ok(()); + }; + + // Reserve first (unpaid) so the amount is not double-paid next cycle. + let mut payout = ReferralPayout { + id: 0, + referral_id: referral.id, + amount: pay_msat, + currency: "BTC".to_string(), + created: Utc::now(), + is_paid: false, + invoice: None, + pre_image: None, + }; + let payout_id = self.db.insert_referral_payout(&payout).await?; + payout.id = payout_id; + + match self.pay_commission(referral, pay_msat).await { + Ok((bolt11, pre_image)) => { + payout.is_paid = true; + payout.invoice = Some(bolt11); + payout.pre_image = pre_image; + self.db.update_referral_payout(&payout).await?; + info!( + "Paid referral commission {} msat to code {} (payout {})", + pay_msat, referral.code, payout_id + ); + let _ = self + .tx + .send(WorkJob::SendNotification { + user_id: referral.user_id, + message: format!( + "You've been paid {} sats in referral commission.", + pay_msat / 1000 + ), + title: Some("Referral payout".to_string()), + }) + .await; + Ok(()) + } + Err(e) => { + // Release the reservation so the balance can be retried later. + if let Err(del) = self.db.delete_referral_payout(payout_id).await { + warn!( + "Failed to release reserved payout {} after payment error: {}", + payout_id, del + ); + } + Err(e) + } + } + } + + /// Resolve a BOLT11 invoice for `amount_msat` from the referrer's chosen + /// payout method and pay it from our node. Returns `(bolt11, preimage)`. + async fn pay_commission( + &self, + referral: &Referral, + amount_msat: u64, + ) -> Result<(String, Option>)> { + let bolt11 = match referral.mode { + ReferralPayoutMode::LightningAddress => { + let addr = referral + .lightning_address + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow!("no lightning address configured"))?; + self.lnurl_pay_invoice(addr, amount_msat).await? + } + ReferralPayoutMode::Nwc => { + #[cfg(feature = "nostr-nwc")] + { + self.nwc_make_invoice(referral.user_id, amount_msat).await? + } + #[cfg(not(feature = "nostr-nwc"))] + { + bail!("NWC payouts are not supported by this build"); + } + } + ReferralPayoutMode::AccountCredit => { + bail!("account credit payouts are not implemented"); + } + }; + + let resp = self + .node + .pay_invoice(PayInvoiceRequest { + invoice: bolt11.clone(), + timeout_seconds: Some(60), + }) + .await?; + let pre_image = resp + .payment_preimage + .and_then(|h| hex::decode(h.trim()).ok()); + Ok((bolt11, pre_image)) + } + + /// Fetch a BOLT11 invoice for `amount_msat` from a Lightning address via + /// LNURL-pay. + async fn lnurl_pay_invoice(&self, address: &str, amount_msat: u64) -> Result { + use lnurl::LnUrlResponse; + use lnurl::lightning_address::LightningAddress; + + let ln_addr = LightningAddress::from_str(address) + .map_err(|_| anyhow!("invalid lightning address"))?; + let client = lnurl::Builder::default() + .build_async() + .map_err(|e| anyhow!("lnurl client: {}", e))?; + let resp = client + .make_request(&ln_addr.lnurlp_url()) + .await + .map_err(|e| anyhow!("lnurl request failed: {}", e))?; + let pay = match resp { + LnUrlResponse::LnUrlPayResponse(p) => p, + _ => bail!("lightning address did not return an LNURL-pay response"), + }; + let invoice = client + .get_invoice(&pay, amount_msat, None, Some("LNVPS referral payout")) + .await + .map_err(|e| anyhow!("failed to fetch LNURL invoice: {}", e))?; + Ok(invoice.pr) + } + + /// Create a BOLT11 invoice for `amount_msat` on the referrer's wallet via + /// their saved NWC connection, so our node can pay it out. + #[cfg(feature = "nostr-nwc")] + async fn nwc_make_invoice(&self, user_id: u64, amount_msat: u64) -> Result { + use nostr_sdk::prelude::*; + + let nwc_method = self + .db + .list_user_payment_methods(user_id, Some("nwc")) + .await? + .into_iter() + .find(|m| m.enabled) + .ok_or_else(|| anyhow!("no enabled NWC payment method"))?; + let nwc_string: String = nwc_method.external_id.clone().into(); + let nwc_uri = NostrWalletConnectUri::from_str(&nwc_string) + .context("Invalid NWC connection string")?; + let client = nwc::NostrWalletConnect::new(nwc_uri); + let rsp = client + .make_invoice(MakeInvoiceRequest { + amount: amount_msat, + description: Some("LNVPS referral payout".to_string()), + description_hash: None, + expiry: None, + }) + .await?; + Ok(rsp.invoice) + } +} + +#[cfg(test)] +mod tests { + use super::payable_referral_msat; + + #[test] + fn test_payable_referral_msat() { + // Below threshold -> None + assert_eq!(payable_referral_msat(500_000, 0, 1_000_000), None); + // At threshold, whole sats -> pays full amount + assert_eq!( + payable_referral_msat(1_000_000, 0, 1_000_000), + Some(1_000_000) + ); + // Existing payouts subtracted; remainder below threshold -> None + assert_eq!(payable_referral_msat(1_500_000, 1_000_000, 1_000_000), None); + // Sub-sat remainder dropped (1_234_567 msat -> 1_234_000 msat) + assert_eq!( + payable_referral_msat(1_234_567, 0, 1_000_000), + Some(1_234_000) + ); + // Owed below a tiny threshold that rounds to zero whole sats -> None + assert_eq!(payable_referral_msat(999, 0, 1), None); + } +} diff --git a/lnvps_api/src/settings.rs b/lnvps_api/src/settings.rs index e594599f..721fa45c 100644 --- a/lnvps_api/src/settings.rs +++ b/lnvps_api/src/settings.rs @@ -66,6 +66,24 @@ pub struct Settings { /// resolve client IPs to a country as VAT place-of-supply evidence. When /// unset, IP geolocation is disabled. pub geoip_database: Option, + + /// Referral program automated payout settings. **Automated payouts are + /// opt-in**: when this section is omitted, commission still accrues and can + /// be paid manually by admins, but no automatic Lightning payouts are made. + pub referral: Option, +} + +fn default_min_payout_sats() -> u64 { + 1000 +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct ReferralConfig { + /// Minimum accrued BTC commission (in satoshis) before an automated payout + /// is attempted for a referrer. Defaults to 1000 sats. + #[serde(default = "default_min_payout_sats")] + pub min_payout_sats: u64, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -344,6 +362,7 @@ pub fn mock_settings() -> Settings { encryption: None, captcha: None, geoip_database: None, + referral: None, } } diff --git a/lnvps_api/src/subscription/mod.rs b/lnvps_api/src/subscription/mod.rs index 0ee9808c..c653e41a 100644 --- a/lnvps_api/src/subscription/mod.rs +++ b/lnvps_api/src/subscription/mod.rs @@ -724,7 +724,8 @@ impl SubscriptionHandler { .await?; // For saved-method payments, charge on the spot and wait for settlement. - self.collect_saved_payment(subscription_payment, &mode).await + self.collect_saved_payment(subscription_payment, &mode) + .await } async fn price_to_payment( @@ -851,8 +852,7 @@ impl SubscriptionHandler { anyhow::anyhow!("Revolut method missing customer id") })? .into(); - let payment_method_id: String = - method.external_id.clone().into(); + let payment_method_id: String = method.external_id.clone().into(); let info = rev .charge_subscription( &customer_id, diff --git a/lnvps_api/src/worker.rs b/lnvps_api/src/worker.rs index 5ad0294e..d60f3965 100644 --- a/lnvps_api/src/worker.rs +++ b/lnvps_api/src/worker.rs @@ -104,6 +104,7 @@ pub struct Worker { feedback: Arc, kv: Arc, http_client: reqwest::Client, + referral_payouts: crate::referral::ReferralPayoutHandler, } #[derive(Clone)] @@ -115,6 +116,9 @@ pub struct WorkerSettings { pub provisioner_config: ProvisionerConfig, pub redis: Option, pub nostr_hostname: Option, + /// Minimum accrued BTC referral commission (satoshis) before an automated + /// payout is attempted. `None` disables automated referral payouts. + pub referral_min_payout_sats: Option, } impl From<&Settings> for WorkerSettings { @@ -127,6 +131,7 @@ impl From<&Settings> for WorkerSettings { provisioner_config: val.provisioner.clone(), redis: val.redis.clone(), nostr_hostname: val.nostr_address_host.clone(), + referral_min_payout_sats: val.referral.as_ref().map(|r| r.min_payout_sats), } } } @@ -138,12 +143,19 @@ impl Worker { db: Arc, work_commander: Arc, subscription_handler: SubscriptionHandler, + node: Arc, settings: impl Into, vm_state_cache: VmStateCache, nostr: Option, ) -> Result { let vm_history_logger = VmHistoryLogger::new(db.clone()); let settings = settings.into(); + let referral_payouts = crate::referral::ReferralPayoutHandler::new( + db.clone(), + node, + work_commander.clone(), + settings.referral_min_payout_sats, + ); let kv: Arc = if let Some(c) = &settings.redis { Arc::new(RedisKeyValueStore::new(&c.url).await?) @@ -173,6 +185,7 @@ impl Worker { settings, work_commander, http_client, + referral_payouts, }) } @@ -1969,6 +1982,9 @@ impl Worker { WorkJob::CheckSubscriptions => { self.check_subscriptions().await?; } + WorkJob::ProcessReferralPayouts => { + self.referral_payouts.process_payouts().await?; + } WorkJob::SyncRouterState => { self.sync_router_state().await?; } @@ -2974,13 +2990,22 @@ mod tests { let sub_handler = SubscriptionHandler::new( settings.clone(), db.clone(), - node, + node.clone(), rates, lnvps_api_common::VatClient::new(), work_commander.clone(), cache.clone(), )?; - Worker::new(db, work_commander, sub_handler, &settings, cache, None).await + Worker::new( + db, + work_commander, + sub_handler, + node, + &settings, + cache, + None, + ) + .await } /// Create a VM linked to a subscription with the given created timestamp and is_setup state. diff --git a/lnvps_api_admin/src/admin/companies.rs b/lnvps_api_admin/src/admin/companies.rs index 343a412b..34edac2b 100644 --- a/lnvps_api_admin/src/admin/companies.rs +++ b/lnvps_api_admin/src/admin/companies.rs @@ -156,6 +156,7 @@ async fn admin_create_company( .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()), base_currency, + referral_rate: req.referral_rate.unwrap_or(0.0).max(0.0), }; let company_id = this.db.admin_create_company(&company).await?; @@ -266,6 +267,12 @@ async fn admin_update_company( company.base_currency = currency; } } + if let Some(rate) = req.referral_rate { + if rate < 0.0 { + return ApiData::err("referral_rate cannot be negative"); + } + company.referral_rate = rate; + } // Update company in database this.db.admin_update_company(&company).await?; diff --git a/lnvps_api_admin/src/admin/mod.rs b/lnvps_api_admin/src/admin/mod.rs index 841a4d50..124c3590 100644 --- a/lnvps_api_admin/src/admin/mod.rs +++ b/lnvps_api_admin/src/admin/mod.rs @@ -18,6 +18,7 @@ mod ip_ranges; mod ip_space; mod model; mod payment_methods; +mod referrals; mod regions; mod reports; mod roles; @@ -68,6 +69,7 @@ pub fn admin_router( .merge(dns_servers::router()) .merge(vm_ip_assignments::router()) .merge(subscriptions::router()) + .merge(referrals::router()) .merge(reports::router()) .merge(websocket::router()) .merge(payment_methods::router()) diff --git a/lnvps_api_admin/src/admin/model.rs b/lnvps_api_admin/src/admin/model.rs index 840bbddb..e63871fd 100644 --- a/lnvps_api_admin/src/admin/model.rs +++ b/lnvps_api_admin/src/admin/model.rs @@ -1827,6 +1827,9 @@ pub struct AdminCompanyInfo { pub phone: Option, pub email: Option, pub base_currency: String, + /// Default referral commission for VMs sold by this company, as a whole + /// percentage of a referred VM's first payment (0 = disabled). + pub referral_rate: f32, pub region_count: u64, // Number of regions assigned to this company } @@ -1843,6 +1846,8 @@ pub struct CreateCompanyRequest { pub phone: Option, pub email: Option, pub base_currency: Option, // 3-letter ISO currency code (default: EUR) + /// Default referral commission %, whole percentage (default 0). + pub referral_rate: Option, } #[derive(Deserialize)] @@ -1858,6 +1863,8 @@ pub struct UpdateCompanyRequest { pub phone: Option, pub email: Option, pub base_currency: Option, // 3-letter ISO currency code + /// Default referral commission %, whole percentage. + pub referral_rate: Option, } impl From for AdminCompanyInfo { @@ -1876,11 +1883,124 @@ impl From for AdminCompanyInfo { phone: company.phone, email: company.email, base_currency: company.base_currency, + referral_rate: company.referral_rate, region_count: 0, // Will be filled by handler } } } +// Referral Program Management Models + +/// A referral enrollment as seen by admins. Never exposes NWC secrets (the NWC +/// connection lives on the user's payment method, not here). +#[derive(Serialize)] +pub struct AdminReferralInfo { + pub id: u64, + pub user_id: u64, + /// Owner's Nostr pubkey (hex), for cross-referencing with users. + pub user_pubkey: String, + pub code: String, + pub lightning_address: Option, + /// Payout method: `lightning_address`, `nwc`, or `account_credit`. + pub mode: String, + /// Per-referrer commission override (whole %); `null` = use company default. + pub referral_rate: Option, + pub created: DateTime, +} + +/// Per-currency earned commission for a referral. +#[derive(Serialize)] +pub struct AdminReferralEarning { + pub currency: String, + /// Commission earned = sum of (first payment * effective_rate%) in this currency. + pub amount: u64, +} + +/// A payout record for a referral (admin view; includes preimage for audit). +#[derive(Serialize)] +pub struct AdminReferralPayoutInfo { + pub id: u64, + pub amount: u64, + pub currency: String, + pub created: DateTime, + pub is_paid: bool, + pub invoice: Option, + /// Payment preimage (hex), when the payout has been settled. + pub pre_image: Option, +} + +impl From for AdminReferralPayoutInfo { + fn from(p: lnvps_db::ReferralPayout) -> Self { + Self { + id: p.id, + amount: p.amount, + currency: p.currency, + created: p.created, + is_paid: p.is_paid, + invoice: p.invoice, + pre_image: p.pre_image.map(hex::encode), + } + } +} + +/// Full referral detail: enrollment + earnings + payout history + counts. +#[derive(Serialize)] +pub struct AdminReferralDetail { + #[serde(flatten)] + pub referral: AdminReferralInfo, + pub earned: Vec, + pub payouts: Vec, + /// Referred VMs that made at least one payment. + pub referrals_success: u64, + /// Referred VMs that never made a payment. + pub referrals_failed: u64, +} + +/// Update a referral's admin-controlled fields (currently the commission override). +#[derive(Deserialize)] +pub struct AdminUpdateReferralRequest { + /// Set (`Some(Some(rate))`) or clear (`Some(None)`) the per-referrer + /// commission override, as a whole percentage; omitted leaves it unchanged. + #[serde( + default, + deserialize_with = "lnvps_api_common::deserialize_nullable_option" + )] + pub referral_rate: Option>, +} + +/// Create a manual payout record for a referral. +#[derive(Deserialize)] +pub struct AdminCreateReferralPayoutRequest { + /// Amount in the smallest currency unit. + pub amount: u64, + /// Currency code (e.g. `BTC`, `EUR`). + pub currency: String, + /// Optional Lightning invoice associated with the payout. + pub invoice: Option, + /// Mark the payout as already paid (e.g. reconciling an out-of-band payment). + #[serde(default)] + pub is_paid: bool, +} + +/// Update / reconcile a payout record. +#[derive(Deserialize)] +pub struct AdminUpdateReferralPayoutRequest { + /// Mark paid / unpaid. + pub is_paid: Option, + /// Set or clear the associated Lightning invoice. + #[serde( + default, + deserialize_with = "lnvps_api_common::deserialize_nullable_option" + )] + pub invoice: Option>, + /// Set or clear the payment preimage (hex-encoded, 32 bytes). + #[serde( + default, + deserialize_with = "lnvps_api_common::deserialize_nullable_option" + )] + pub pre_image: Option>, +} + // IP Range Management Models #[derive(Serialize)] pub struct AdminIpRangeInfo { diff --git a/lnvps_api_admin/src/admin/referrals.rs b/lnvps_api_admin/src/admin/referrals.rs new file mode 100644 index 00000000..29da440a --- /dev/null +++ b/lnvps_api_admin/src/admin/referrals.rs @@ -0,0 +1,254 @@ +use crate::admin::RouterState; +use crate::admin::auth::AdminAuth; +use crate::admin::model::{ + AdminCreateReferralPayoutRequest, AdminReferralDetail, AdminReferralEarning, AdminReferralInfo, + AdminReferralPayoutInfo, AdminUpdateReferralPayoutRequest, AdminUpdateReferralRequest, +}; +use axum::extract::{Path, Query, State}; +use axum::routing::get; +use axum::{Json, Router}; +use lnvps_api_common::{ + ApiData, ApiError, ApiPaginatedData, ApiPaginatedResult, ApiResult, + deserialize_from_str_optional, +}; +use lnvps_db::{AdminAction, AdminResource, Referral, ReferralPayout}; +use std::collections::HashMap; + +pub fn router() -> Router { + Router::new() + .route("/api/admin/v1/referrals", get(admin_list_referrals)) + .route( + "/api/admin/v1/referrals/{id}", + get(admin_get_referral).patch(admin_update_referral), + ) + .route( + "/api/admin/v1/referrals/{id}/payouts", + get(admin_list_referral_payouts).post(admin_create_referral_payout), + ) + .route( + "/api/admin/v1/referrals/{id}/payouts/{payout_id}", + axum::routing::patch(admin_update_referral_payout), + ) +} + +#[derive(serde::Deserialize, Default)] +#[serde(default)] +struct ListReferralsQuery { + #[serde(deserialize_with = "deserialize_from_str_optional")] + limit: Option, + #[serde(deserialize_with = "deserialize_from_str_optional")] + offset: Option, + /// Substring match on referral code, or a 64-char hex user pubkey. + search: Option, +} + +/// Build the admin view of a referral, resolving the owner's pubkey. +async fn build_info(this: &RouterState, r: Referral) -> Result { + let user = this.db.get_user(r.user_id).await?; + Ok(AdminReferralInfo { + id: r.id, + user_id: r.user_id, + user_pubkey: hex::encode(user.pubkey), + code: r.code, + lightning_address: r.lightning_address, + mode: r.mode.to_string(), + referral_rate: r.referral_rate, + created: r.created, + }) +} + +/// List referral enrollments (paginated, optional search). +async fn admin_list_referrals( + auth: AdminAuth, + State(this): State, + Query(params): Query, +) -> ApiPaginatedResult { + auth.require_permission(AdminResource::Referral, AdminAction::View)?; + + let limit = params.limit.unwrap_or(50).min(100); + let offset = params.offset.unwrap_or(0); + let search = params.search.as_deref().filter(|s| !s.trim().is_empty()); + + let (rows, total) = this.db.admin_list_referrals(limit, offset, search).await?; + + let mut out = Vec::with_capacity(rows.len()); + for r in rows { + out.push(build_info(&this, r).await?); + } + ApiPaginatedData::ok(out, total, limit, offset) +} + +/// Get a referral with its earnings and payout history. +async fn admin_get_referral( + auth: AdminAuth, + State(this): State, + Path(id): Path, +) -> ApiResult { + auth.require_permission(AdminResource::Referral, AdminAction::View)?; + + let referral = this.db.admin_get_referral(id).await?; + let code = referral.code.clone(); + + let (usage, payouts, referrals_failed) = tokio::try_join!( + this.db.list_referral_usage(&code), + this.db.list_referral_payouts(id), + this.db.count_failed_referrals(&code), + )?; + + // Aggregate commission earned per currency. + let mut by_currency: HashMap = HashMap::new(); + for u in &usage { + *by_currency.entry(u.currency.clone()).or_insert(0) += u.commission(); + } + let mut earned: Vec = by_currency + .into_iter() + .map(|(currency, amount)| AdminReferralEarning { currency, amount }) + .collect(); + earned.sort_by(|a, b| a.currency.cmp(&b.currency)); + + let referrals_success = usage.len() as u64; + let info = build_info(&this, referral).await?; + + ApiData::ok(AdminReferralDetail { + referral: info, + earned, + payouts: payouts.into_iter().map(Into::into).collect(), + referrals_success, + referrals_failed, + }) +} + +/// Set or clear a referral's per-referrer commission override. +async fn admin_update_referral( + auth: AdminAuth, + State(this): State, + Path(id): Path, + Json(req): Json, +) -> ApiResult { + auth.require_permission(AdminResource::Referral, AdminAction::Update)?; + + let mut referral = this.db.admin_get_referral(id).await?; + + if let Some(rate) = req.referral_rate { + if let Some(r) = rate { + if r < 0.0 { + return ApiData::err("referral_rate cannot be negative"); + } + } + referral.referral_rate = rate; + } + + this.db.update_referral(&referral).await?; + let updated = this.db.admin_get_referral(id).await?; + ApiData::ok(build_info(&this, updated).await?) +} + +/// List a referral's payout records. +async fn admin_list_referral_payouts( + auth: AdminAuth, + State(this): State, + Path(id): Path, +) -> ApiResult> { + auth.require_permission(AdminResource::Referral, AdminAction::View)?; + + // Ensure the referral exists for a clean 404. + let _ = this.db.admin_get_referral(id).await?; + let payouts = this.db.list_referral_payouts(id).await?; + ApiData::ok(payouts.into_iter().map(Into::into).collect()) +} + +/// Create a manual payout record for a referral (e.g. an out-of-band payment). +async fn admin_create_referral_payout( + auth: AdminAuth, + State(this): State, + Path(id): Path, + Json(req): Json, +) -> ApiResult { + auth.require_permission(AdminResource::Referral, AdminAction::Create)?; + + let _ = this.db.admin_get_referral(id).await?; + + if req.amount == 0 { + return ApiData::err("amount must be greater than 0"); + } + let currency = req.currency.trim().to_uppercase(); + if currency.is_empty() { + return ApiData::err("currency is required"); + } + + let payout = ReferralPayout { + id: 0, + referral_id: id, + amount: req.amount, + currency, + created: chrono::Utc::now(), + is_paid: false, + invoice: req.invoice.filter(|s| !s.trim().is_empty()), + pre_image: None, + }; + let payout_id = this.db.insert_referral_payout(&payout).await?; + + // Apply the initial paid flag if requested (insert defaults to unpaid). + if req.is_paid { + let mut created = ReferralPayout { + id: payout_id, + ..payout.clone() + }; + created.is_paid = true; + this.db.update_referral_payout(&created).await?; + } + + let created = this + .db + .list_referral_payouts(id) + .await? + .into_iter() + .find(|p| p.id == payout_id) + .ok_or_else(|| ApiError::new("Failed to load created payout"))?; + ApiData::ok(created.into()) +} + +/// Update / reconcile a payout record (mark paid, set invoice / preimage). +async fn admin_update_referral_payout( + auth: AdminAuth, + State(this): State, + Path((id, payout_id)): Path<(u64, u64)>, + Json(req): Json, +) -> ApiResult { + auth.require_permission(AdminResource::Referral, AdminAction::Update)?; + + let mut payout = this + .db + .list_referral_payouts(id) + .await? + .into_iter() + .find(|p| p.id == payout_id) + .ok_or_else(|| ApiError::not_found("Payout not found for this referral"))?; + + if let Some(is_paid) = req.is_paid { + payout.is_paid = is_paid; + } + if let Some(invoice) = req.invoice { + payout.invoice = invoice.filter(|s| !s.trim().is_empty()); + } + if let Some(pre_image) = req.pre_image { + payout.pre_image = match pre_image.filter(|s| !s.trim().is_empty()) { + Some(hex_str) => Some( + hex::decode(hex_str.trim()) + .map_err(|_| ApiError::bad_request("pre_image must be hex-encoded"))?, + ), + None => None, + }; + } + + this.db.update_referral_payout(&payout).await?; + + let updated = this + .db + .list_referral_payouts(id) + .await? + .into_iter() + .find(|p| p.id == payout_id) + .ok_or_else(|| ApiError::new("Failed to load updated payout"))?; + ApiData::ok(updated.into()) +} diff --git a/lnvps_api_admin/src/admin/reports.rs b/lnvps_api_admin/src/admin/reports.rs index 4cbf4428..868cea48 100644 --- a/lnvps_api_admin/src/admin/reports.rs +++ b/lnvps_api_admin/src/admin/reports.rs @@ -57,6 +57,10 @@ struct ReferralReport { currency: String, rate: f32, base_currency: String, + /// Effective commission rate applied (referrer override or company default), %. + effective_rate: f32, + /// Commission earned = amount * effective_rate%, in `currency` smallest units. + commission: u64, } #[derive(Serialize, Deserialize)] @@ -235,14 +239,19 @@ async fn admin_referral_time_series_report( let mut referrals: Vec = referral_data .into_iter() - .map(|data| ReferralReport { - vm_id: data.vm_id, - ref_code: data.ref_code, - created: data.created.to_rfc3339(), - amount: data.amount, - currency: data.currency, - rate: data.rate, - base_currency: data.base_currency, + .map(|data| { + let commission = data.commission(); + ReferralReport { + vm_id: data.vm_id, + ref_code: data.ref_code, + created: data.created.to_rfc3339(), + amount: data.amount, + currency: data.currency, + rate: data.rate, + base_currency: data.base_currency, + effective_rate: data.effective_rate, + commission, + } }) .collect(); diff --git a/lnvps_api_admin/src/bin/generate_demo_data.rs b/lnvps_api_admin/src/bin/generate_demo_data.rs index 2de6a5bf..8ff787fd 100644 --- a/lnvps_api_admin/src/bin/generate_demo_data.rs +++ b/lnvps_api_admin/src/bin/generate_demo_data.rs @@ -153,6 +153,7 @@ async fn create_companies(db: &LNVpsDbMysql) -> Result> { country_code: Some("USA".to_string()), tax_id: Some("US123456789".to_string()), base_currency: "USD".to_string(), + referral_rate: 0.0, address_2: None, }, Company { @@ -168,6 +169,7 @@ async fn create_companies(db: &LNVpsDbMysql) -> Result> { country_code: Some("GBR".to_string()), tax_id: Some("GB987654321".to_string()), base_currency: "GBP".to_string(), + referral_rate: 0.0, address_2: None, }, Company { @@ -183,6 +185,7 @@ async fn create_companies(db: &LNVpsDbMysql) -> Result> { country_code: Some("DEU".to_string()), tax_id: Some("DE555777999".to_string()), base_currency: "EUR".to_string(), + referral_rate: 0.0, address_2: None, }, ]; diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index 4bd79c1f..8785bdc3 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -275,6 +275,7 @@ impl Default for MockDb { phone: None, email: None, base_currency: "EUR".to_string(), + referral_rate: 0.0, }, ); companies @@ -2479,11 +2480,31 @@ impl LNVpsDbBase for MockDb { 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; + r.mode = referral.mode; + r.referral_rate = referral.referral_rate; } Ok(()) } + async fn delete_referral(&self, referral_id: u64) -> DbResult<()> { + let mut referrals = self.referrals.lock().await; + referrals.remove(&referral_id); + Ok(()) + } + + async fn list_all_referrals(&self) -> DbResult> { + let referrals = self.referrals.lock().await; + let mut all: Vec = referrals.values().cloned().collect(); + all.sort_by_key(|r| r.id); + Ok(all) + } + + async fn delete_referral_payout(&self, payout_id: u64) -> DbResult<()> { + let mut payouts = self.referral_payouts.lock().await; + payouts.retain(|p| p.id != payout_id); + 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; @@ -2498,6 +2519,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.invoice = payout.invoice.clone(); p.pre_image = payout.pre_image.clone(); } Ok(()) @@ -2516,6 +2538,24 @@ impl LNVpsDbBase for MockDb { let vms = self.vms.lock().await; let line_items = self.subscription_line_items.lock().await; let sub_payments = self.subscription_payments.lock().await; + // Effective rate: referrer override, else the default company's rate. + let effective_rate = { + let referrals = self.referrals.lock().await; + let override_rate = referrals + .values() + .find(|r| r.code == code) + .and_then(|r| r.referral_rate); + match override_rate { + Some(r) => r, + None => self + .companies + .lock() + .await + .get(&1) + .map(|c| c.referral_rate) + .unwrap_or(0.0), + } + }; let mut result = Vec::new(); for vm in vms.values().filter(|v| v.ref_code.as_deref() == Some(code)) { let subscription_id = line_items @@ -2536,6 +2576,7 @@ impl LNVpsDbBase for MockDb { currency: first.currency.clone(), rate: first.rate, base_currency: "EUR".to_string(), + effective_rate, }); } } @@ -3096,6 +3137,40 @@ impl lnvps_db::AdminDb for MockDb { // Mock implementation - return empty for now Ok(vec![]) } + + async fn admin_list_referrals( + &self, + limit: u64, + offset: u64, + search: Option<&str>, + ) -> DbResult<(Vec, u64)> { + let referrals = self.referrals.lock().await; + let mut all: Vec = referrals + .values() + .filter(|r| match search { + Some(s) if !s.trim().is_empty() => r.code.contains(s.trim()), + _ => true, + }) + .cloned() + .collect(); + all.sort_by(|a, b| b.created.cmp(&a.created)); + let total = all.len() as u64; + let page = all + .into_iter() + .skip(offset as usize) + .take(limit as usize) + .collect(); + Ok((page, total)) + } + + async fn admin_get_referral(&self, referral_id: u64) -> DbResult { + let referrals = self.referrals.lock().await; + referrals + .get(&referral_id) + .cloned() + .ok_or_else(|| DbError::Other(anyhow!("referral not found"))) + } + async fn admin_list_ip_ranges( &self, _limit: u64, @@ -3445,6 +3520,71 @@ mod tests { use super::*; use lnvps_db::{IntervalType, LNVpsDbBase, SubscriptionPaymentType}; + /// list_all_referrals + delete_referral base-trait methods. + #[tokio::test] + async fn test_referral_delete_and_list_all() { + use lnvps_db::{Referral, ReferralPayoutMode}; + + let db = MockDb::default(); + let mk = |code: &str| Referral { + id: 0, + user_id: 1, + code: code.to_string(), + lightning_address: Some("a@b.com".to_string()), + mode: ReferralPayoutMode::LightningAddress, + referral_rate: None, + created: Utc::now(), + }; + let id_a = db.insert_referral(&mk("AAA")).await.unwrap(); + db.insert_referral(&mk("BBB")).await.unwrap(); + assert_eq!(db.list_all_referrals().await.unwrap().len(), 2); + + db.delete_referral(id_a).await.unwrap(); + let rest = db.list_all_referrals().await.unwrap(); + assert_eq!(rest.len(), 1); + assert_eq!(rest[0].code, "BBB"); + } + + /// admin_list_referrals (pagination + code search) and admin_get_referral. + #[cfg(feature = "admin")] + #[tokio::test] + async fn test_admin_referral_listing() { + use lnvps_db::{AdminDb, Referral, ReferralPayoutMode}; + + let db = MockDb::default(); + let mk = |code: &str| Referral { + id: 0, + user_id: 1, + code: code.to_string(), + lightning_address: Some("a@b.com".to_string()), + mode: ReferralPayoutMode::LightningAddress, + referral_rate: None, + created: Utc::now(), + }; + let id_a = db.insert_referral(&mk("ALPHA123")).await.unwrap(); + db.insert_referral(&mk("BETA456")).await.unwrap(); + + // List all + let (rows, total) = db.admin_list_referrals(50, 0, None).await.unwrap(); + assert_eq!(total, 2); + assert_eq!(rows.len(), 2); + + // Search by code substring + let (rows, total) = db.admin_list_referrals(50, 0, Some("ALPHA")).await.unwrap(); + assert_eq!(total, 1); + assert_eq!(rows[0].code, "ALPHA123"); + + // Pagination + let (rows, total) = db.admin_list_referrals(1, 0, None).await.unwrap(); + assert_eq!(total, 2); + assert_eq!(rows.len(), 1); + + // Get by id + let got = db.admin_get_referral(id_a).await.unwrap(); + assert_eq!(got.code, "ALPHA123"); + assert!(db.admin_get_referral(9999).await.is_err()); + } + /// user_payment_method CRUD + provider filter via the mock DB. #[tokio::test] async fn test_user_payment_method_crud() { diff --git a/lnvps_api_common/src/pricing.rs b/lnvps_api_common/src/pricing.rs index 9f54c99d..9e9e5f01 100644 --- a/lnvps_api_common/src/pricing.rs +++ b/lnvps_api_common/src/pricing.rs @@ -1914,7 +1914,10 @@ mod tests { .calculate_processing_fee(1, PaymentMethod::Revolut, Currency::EUR, gross) .await; assert_eq!(fee_on_gross, quote.processing_fee); - assert!(quote.processing_fee > 0, "expected a non-zero processing fee"); + assert!( + quote.processing_fee > 0, + "expected a non-zero processing fee" + ); Ok(()) } @@ -2542,7 +2545,12 @@ mod tests { // …and it must be strictly larger than the (buggy) fee on net alone, // proving the tax portion is now included in the gross-up base. let fee_on_net = pe - .calculate_processing_fee(1, PaymentMethod::Revolut, Currency::EUR, payment_info.amount) + .calculate_processing_fee( + 1, + PaymentMethod::Revolut, + Currency::EUR, + payment_info.amount, + ) .await; assert!( payment_info.processing_fee > fee_on_net, diff --git a/lnvps_api_common/src/work/mod.rs b/lnvps_api_common/src/work/mod.rs index 1fd719dd..c789532c 100644 --- a/lnvps_api_common/src/work/mod.rs +++ b/lnvps_api_common/src/work/mod.rs @@ -128,6 +128,8 @@ pub enum WorkJob { DownloadOsImages { image_id: Option }, /// Check all active subscriptions for expiry, auto-renewal, and deactivation. CheckSubscriptions, + /// Process automated referral commission payouts (BTC, over Lightning). + ProcessReferralPayouts, /// Poll routers to refresh cached tunnel/BGP session/route state and record /// per-tunnel traffic samples. SyncRouterState, @@ -199,6 +201,7 @@ impl fmt::Display for WorkJob { WorkJob::SendEmailVerification { .. } => write!(f, "SendEmailVerification"), WorkJob::DownloadOsImages { .. } => write!(f, "DownloadOsImages"), WorkJob::CheckSubscriptions => write!(f, "CheckSubscriptions"), + WorkJob::ProcessReferralPayouts => write!(f, "ProcessReferralPayouts"), WorkJob::SpawnVm { .. } => write!(f, "SpawnVm"), WorkJob::SyncRouterState => write!(f, "SyncRouterState"), WorkJob::ToggleBgpSession { .. } => write!(f, "ToggleBgpSession"), diff --git a/lnvps_db/migrations/20260718000000_referral_commission_rate.sql b/lnvps_db/migrations/20260718000000_referral_commission_rate.sql new file mode 100644 index 00000000..6409975e --- /dev/null +++ b/lnvps_db/migrations/20260718000000_referral_commission_rate.sql @@ -0,0 +1,17 @@ +-- Referral commission model: percentage of a referred VM's first payment. +-- +-- The effective rate is per-referrer with a company default: +-- * `company.referral_rate` — default commission % for VMs sold by that +-- company (applies when the referrer has no override). NOT NULL, default 0 +-- so the program pays nothing until a rate is configured. +-- * `referral.referral_rate` — optional per-referrer override (NULL = fall +-- back to the referred VM's company default). Applies to all of that +-- referrer's referrals when set. +-- +-- Rates are whole percentages (e.g. 10.0 = 10%). + +ALTER TABLE company + ADD COLUMN referral_rate FLOAT NOT NULL DEFAULT 0; + +ALTER TABLE referral + ADD COLUMN referral_rate FLOAT NULL DEFAULT NULL; diff --git a/lnvps_db/migrations/20260718001000_referral_payout_mode.sql b/lnvps_db/migrations/20260718001000_referral_payout_mode.sql new file mode 100644 index 00000000..8c42b190 --- /dev/null +++ b/lnvps_db/migrations/20260718001000_referral_payout_mode.sql @@ -0,0 +1,15 @@ +-- Replace the referral `use_nwc` boolean with a flexible `mode` enum column so +-- new payout methods (e.g. account credit) can be added without further boolean +-- flags. +-- +-- ReferralPayoutMode: LightningAddress = 0, Nwc = 1, AccountCredit = 2 +-- +-- Migrate existing rows: use_nwc = 1 -> Nwc (1); otherwise LightningAddress (0). + +ALTER TABLE referral + ADD COLUMN mode SMALLINT UNSIGNED NOT NULL DEFAULT 0; + +UPDATE referral SET mode = 1 WHERE use_nwc = 1; + +ALTER TABLE referral + DROP COLUMN use_nwc; diff --git a/lnvps_db/migrations/20260718002000_referral_rbac_permissions.sql b/lnvps_db/migrations/20260718002000_referral_rbac_permissions.sql new file mode 100644 index 00000000..91d65efb --- /dev/null +++ b/lnvps_db/migrations/20260718002000_referral_rbac_permissions.sql @@ -0,0 +1,16 @@ +-- RBAC permissions for referral program management. +-- +-- Adds AdminResource::Referral = 25. Referral administration (viewing referrers, +-- setting per-referrer commission overrides, creating/reconciling payouts) is +-- admin-only, so grant the full permission set to the default super_admin role, +-- following the established per-feature grant convention. +-- +-- AdminAction: Create = 0, View = 1, Update = 2, Delete = 3 +INSERT IGNORE INTO admin_role_permissions (role_id, resource, action, created_at) +SELECT id, 25, 0, NOW() FROM admin_roles WHERE name = 'super_admin' +UNION ALL +SELECT id, 25, 1, NOW() FROM admin_roles WHERE name = 'super_admin' +UNION ALL +SELECT id, 25, 2, NOW() FROM admin_roles WHERE name = 'super_admin' +UNION ALL +SELECT id, 25, 3, NOW() FROM admin_roles WHERE name = 'super_admin'; diff --git a/lnvps_db/src/admin.rs b/lnvps_db/src/admin.rs index 51f9071d..ba98ac21 100644 --- a/lnvps_db/src/admin.rs +++ b/lnvps_db/src/admin.rs @@ -254,6 +254,20 @@ pub trait AdminDb: Send + Sync { ref_code: Option<&str>, ) -> DbResult>; + // Referral program administration + /// List referral enrollments with pagination. `search` (optional) matches a + /// referral code (substring) or, when it is a 64-char hex pubkey, the owning + /// user. Ordered by `created DESC`. Returns `(rows, total)`. + async fn admin_list_referrals( + &self, + limit: u64, + offset: u64, + search: Option<&str>, + ) -> DbResult<(Vec, u64)>; + + /// Get a single referral enrollment by its id. + async fn admin_get_referral(&self, referral_id: u64) -> DbResult; + // IP Range management methods /// List all IP ranges with pagination async fn admin_list_ip_ranges( diff --git a/lnvps_db/src/lib.rs b/lnvps_db/src/lib.rs index 6f435ba6..a6d0447c 100644 --- a/lnvps_db/src/lib.rs +++ b/lnvps_db/src/lib.rs @@ -802,12 +802,23 @@ pub trait LNVpsDbBase: Send + Sync { /// Update an existing referral entry async fn update_referral(&self, referral: &Referral) -> DbResult<()>; + /// Delete a referral enrollment (used when a user leaves the program). The + /// caller must ensure no payout records reference it (the FK has no cascade). + async fn delete_referral(&self, referral_id: u64) -> DbResult<()>; + + /// List every referral enrollment (used by the automated payout job). + async fn list_all_referrals(&self) -> 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<()>; + /// Delete a referral payout record (used to release a reserved payout when + /// the outgoing Lightning payment fails, so the balance can be retried). + async fn delete_referral_payout(&self, payout_id: u64) -> DbResult<()>; + /// List all payout records for a referral async fn list_referral_payouts(&self, referral_id: u64) -> DbResult>; diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index cd3d4baa..4b7c195b 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -1316,10 +1316,15 @@ pub struct Referral { 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, + /// How the referrer is paid their commission. + pub mode: ReferralPayoutMode, /// When this referral entry was created pub created: DateTime, + /// Optional per-referrer commission override, as a whole percentage of a + /// referred VM's first payment. `None` falls back to the referred VM's + /// `company.referral_rate` default. + #[sqlx(default)] + pub referral_rate: Option, } #[derive(FromRow, Clone, Debug, Default)] @@ -1351,6 +1356,58 @@ pub struct ReferralCostUsage { pub currency: String, pub rate: f32, pub base_currency: String, + /// Effective commission rate applied to this referred VM's first payment, as + /// a whole percentage: the referrer's override if set, else the referred + /// VM's `company.referral_rate` default. + #[sqlx(default)] + pub effective_rate: f32, +} + +impl ReferralCostUsage { + /// Commission earned on this referral: `amount * effective_rate%`, floored, + /// in the same smallest-currency unit as `amount`. + pub fn commission(&self) -> u64 { + ((self.amount as f64) * (self.effective_rate as f64 / 100.0)).floor() as u64 + } +} + +/// How a referrer receives their commission payouts. Stored as a small integer; +/// new methods are added as new variants (append-only to preserve values). +#[derive(Type, Clone, Copy, Debug, Default, PartialEq, Eq)] +#[repr(u16)] +pub enum ReferralPayoutMode { + /// Pay to the referrer's Lightning address (LNURL-pay). + #[default] + LightningAddress = 0, + /// Pay via the referrer's Nostr Wallet Connect (NWC) connection. + Nwc = 1, + /// Credit the referrer's account balance (not yet implemented). + AccountCredit = 2, +} + +impl Display for ReferralPayoutMode { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + ReferralPayoutMode::LightningAddress => "lightning_address", + ReferralPayoutMode::Nwc => "nwc", + ReferralPayoutMode::AccountCredit => "account_credit", + }) + } +} + +impl FromStr for ReferralPayoutMode { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s.trim().to_lowercase().as_str() { + "lightning_address" | "lightning" | "lnaddress" => { + Ok(ReferralPayoutMode::LightningAddress) + } + "nwc" => Ok(ReferralPayoutMode::Nwc), + "account_credit" | "credit" => Ok(ReferralPayoutMode::AccountCredit), + other => anyhow::bail!("Invalid referral payout mode: {}", other), + } + } } #[derive(Type, Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -1456,6 +1513,11 @@ pub struct Company { pub phone: Option, pub email: Option, pub base_currency: String, + /// Default referral commission, as a whole percentage of a referred VM's + /// first payment (e.g. `10.0` = 10%). Applies when the referrer has no + /// per-referrer override. `0` disables commission for this company. + #[sqlx(default)] + pub referral_rate: f32, } #[derive(Clone, Debug, Default)] @@ -1598,6 +1660,7 @@ pub enum AdminResource { DnsServer = 22, UserPaymentMethod = 23, ResourceCost = 24, + Referral = 25, } /// Actions that can be performed on administrative resources @@ -1638,6 +1701,7 @@ impl Display for AdminResource { AdminResource::DnsServer => write!(f, "dns_server"), AdminResource::UserPaymentMethod => write!(f, "user_payment_method"), AdminResource::ResourceCost => write!(f, "resource_cost"), + AdminResource::Referral => write!(f, "referral"), } } } @@ -1672,6 +1736,7 @@ impl FromStr for AdminResource { "dns_server" => Ok(AdminResource::DnsServer), "user_payment_method" => Ok(AdminResource::UserPaymentMethod), "resource_cost" => Ok(AdminResource::ResourceCost), + "referral" => Ok(AdminResource::Referral), _ => Err(anyhow!("unknown admin resource: {}", s)), } } @@ -1707,6 +1772,7 @@ impl TryFrom for AdminResource { 22 => Ok(AdminResource::DnsServer), 23 => Ok(AdminResource::UserPaymentMethod), 24 => Ok(AdminResource::ResourceCost), + 25 => Ok(AdminResource::Referral), _ => Err(anyhow!("unknown admin resource value: {}", value)), } } @@ -1741,6 +1807,7 @@ impl AdminResource { AdminResource::DnsServer, AdminResource::UserPaymentMethod, AdminResource::ResourceCost, + AdminResource::Referral, ] } } @@ -2042,6 +2109,55 @@ impl InternetRegistry { mod tests { use super::*; + #[test] + fn test_referral_payout_mode_roundtrip() { + for (s, m) in [ + ("lightning_address", ReferralPayoutMode::LightningAddress), + ("nwc", ReferralPayoutMode::Nwc), + ("account_credit", ReferralPayoutMode::AccountCredit), + ] { + assert_eq!(ReferralPayoutMode::from_str(s).unwrap(), m); + assert_eq!(m.to_string(), s); + } + // Aliases + case-insensitivity + assert_eq!( + ReferralPayoutMode::from_str("NWC").unwrap(), + ReferralPayoutMode::Nwc + ); + assert_eq!( + ReferralPayoutMode::from_str("lightning").unwrap(), + ReferralPayoutMode::LightningAddress + ); + assert!(ReferralPayoutMode::from_str("bogus").is_err()); + assert_eq!( + ReferralPayoutMode::default(), + ReferralPayoutMode::LightningAddress + ); + } + + #[test] + fn test_referral_cost_usage_commission() { + let mut u = ReferralCostUsage { + vm_id: 1, + ref_code: "X".to_string(), + created: Utc::now(), + amount: 10_000, + currency: "EUR".to_string(), + rate: 1.0, + base_currency: "EUR".to_string(), + effective_rate: 10.0, + }; + // 10% of 10_000 = 1_000 + assert_eq!(u.commission(), 1_000); + // 0% disables commission + u.effective_rate = 0.0; + assert_eq!(u.commission(), 0); + // Floored (2.5% of 101 = 2.525 -> 2) + u.amount = 101; + u.effective_rate = 2.5; + assert_eq!(u.commission(), 2); + } + #[test] fn test_user_payment_method_is_expired() { let mut pm = UserPaymentMethod { @@ -2127,6 +2243,20 @@ mod tests { assert!(AdminResource::all().contains(&AdminResource::ResourceCost)); } + #[test] + fn test_admin_resource_referral_roundtrip() { + assert_eq!( + "referral".parse::().unwrap(), + AdminResource::Referral + ); + assert_eq!(AdminResource::Referral.to_string(), "referral"); + assert_eq!( + AdminResource::try_from(25u16).unwrap(), + AdminResource::Referral + ); + assert!(AdminResource::all().contains(&AdminResource::Referral)); + } + #[test] fn test_cost_type_and_resource_type_roundtrip() { assert_eq!( diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index d17d3509..14f1be74 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -2924,22 +2924,48 @@ impl LNVpsDbBase for LNVpsDbMysql { 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", + "INSERT INTO referral (user_id, code, lightning_address, mode, referral_rate) VALUES (?, ?, ?, ?, ?) returning id", ) .bind(referral.user_id) .bind(&referral.code) .bind(&referral.lightning_address) - .bind(referral.use_nwc) + .bind(referral.mode) + .bind(referral.referral_rate) .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) + sqlx::query( + "UPDATE referral SET lightning_address = ?, mode = ?, referral_rate = ? WHERE id = ?", + ) + .bind(&referral.lightning_address) + .bind(referral.mode) + .bind(referral.referral_rate) + .bind(referral.id) + .execute(&self.db) + .await?; + Ok(()) + } + + async fn delete_referral(&self, referral_id: u64) -> DbResult<()> { + sqlx::query("DELETE FROM referral WHERE id = ?") + .bind(referral_id) + .execute(&self.db) + .await?; + Ok(()) + } + + async fn list_all_referrals(&self) -> DbResult> { + Ok(sqlx::query_as("SELECT * FROM referral ORDER BY id") + .fetch_all(&self.db) + .await?) + } + + async fn delete_referral_payout(&self, payout_id: u64) -> DbResult<()> { + sqlx::query("DELETE FROM referral_payout WHERE id = ?") + .bind(payout_id) .execute(&self.db) .await?; Ok(()) @@ -2959,12 +2985,15 @@ impl LNVpsDbBase for LNVpsDbMysql { } 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?; + sqlx::query( + "UPDATE referral_payout SET is_paid = ?, invoice = ?, pre_image = ? WHERE id = ?", + ) + .bind(payout.is_paid) + .bind(&payout.invoice) + .bind(&payout.pre_image) + .bind(payout.id) + .execute(&self.db) + .await?; Ok(()) } @@ -2985,7 +3014,8 @@ impl LNVpsDbBase for LNVpsDbMysql { sp.amount, sp.currency, sp.rate, - c.base_currency + c.base_currency, + COALESCE(r.referral_rate, c.referral_rate) AS effective_rate FROM vm v JOIN ( SELECT v2.id as vm_id, sp2.currency, sp2.amount, sp2.created, sp2.rate, @@ -2999,6 +3029,7 @@ impl LNVpsDbBase for LNVpsDbMysql { 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 + LEFT JOIN referral r ON r.code = v.ref_code WHERE v.ref_code = ? ORDER BY sp.created DESC", ) @@ -4337,8 +4368,8 @@ impl AdminDb for LNVpsDbMysql { async fn admin_create_company(&self, company: &Company) -> DbResult { let result = sqlx::query( - r#"INSERT INTO company (name, address_1, address_2, city, state, country_code, tax_id, postcode, phone, email, created, base_currency) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), ?)"#, + r#"INSERT INTO company (name, address_1, address_2, city, state, country_code, tax_id, postcode, phone, email, created, base_currency, referral_rate) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), ?, ?)"#, ) .bind(&company.name) .bind(&company.address_1) @@ -4351,6 +4382,7 @@ impl AdminDb for LNVpsDbMysql { .bind(&company.phone) .bind(&company.email) .bind(&company.base_currency) + .bind(company.referral_rate) .execute(&self.db) .await?; @@ -4361,7 +4393,7 @@ impl AdminDb for LNVpsDbMysql { sqlx::query( r#"UPDATE company SET name = ?, address_1 = ?, address_2 = ?, city = ?, state = ?, - country_code = ?, tax_id = ?, postcode = ?, phone = ?, email = ?, base_currency = ? + country_code = ?, tax_id = ?, postcode = ?, phone = ?, email = ?, base_currency = ?, referral_rate = ? WHERE id = ?"#, ) .bind(&company.name) @@ -4375,6 +4407,7 @@ impl AdminDb for LNVpsDbMysql { .bind(&company.phone) .bind(&company.email) .bind(&company.base_currency) + .bind(company.referral_rate) .bind(company.id) .execute(&self.db) .await?; @@ -4471,7 +4504,8 @@ impl AdminDb for LNVpsDbMysql { sp.amount, sp.currency, sp.rate, - c.base_currency + c.base_currency, + COALESCE(r.referral_rate, c.referral_rate) AS effective_rate FROM vm v JOIN ( SELECT v2.id as vm_id, sp2.currency, sp2.amount, sp2.created, sp2.rate, @@ -4485,6 +4519,7 @@ impl AdminDb for LNVpsDbMysql { 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 + LEFT JOIN referral r ON r.code = v.ref_code WHERE v.ref_code IS NOT NULL AND sp.created >= ? AND sp.created <= ? @@ -4508,6 +4543,58 @@ impl AdminDb for LNVpsDbMysql { Ok(db_query.fetch_all(&self.db).await?) } + async fn admin_list_referrals( + &self, + limit: u64, + offset: u64, + search: Option<&str>, + ) -> DbResult<(Vec, u64)> { + // A 64-char hex search term is treated as a user pubkey (matched via + // SQL HEX() so the DB layer needs no hex dependency); otherwise the term + // is matched against the referral code as a substring. + let pubkey_hex = search + .map(str::trim) + .filter(|s| s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit())); + let code_like = match (pubkey_hex, search) { + (None, Some(s)) if !s.trim().is_empty() => Some(format!("%{}%", s.trim())), + _ => None, + }; + + let where_clause = if pubkey_hex.is_some() { + "WHERE r.user_id = (SELECT id FROM users WHERE HEX(pubkey) = ?)" + } else if code_like.is_some() { + "WHERE r.code LIKE ?" + } else { + "" + }; + + let list_sql = format!( + "SELECT r.* FROM referral r {where_clause} ORDER BY r.created DESC LIMIT ? OFFSET ?" + ); + let count_sql = format!("SELECT COUNT(*) FROM referral r {where_clause}"); + + let mut list_q = sqlx::query_as::<_, Referral>(&list_sql); + let mut count_q = sqlx::query_scalar::<_, i64>(&count_sql); + if let Some(pk) = pubkey_hex { + let up = pk.to_uppercase(); + list_q = list_q.bind(up.clone()); + count_q = count_q.bind(up); + } else if let Some(like) = &code_like { + list_q = list_q.bind(like.clone()); + count_q = count_q.bind(like.clone()); + } + let rows = list_q.bind(limit).bind(offset).fetch_all(&self.db).await?; + let total = count_q.fetch_one(&self.db).await? as u64; + Ok((rows, total)) + } + + async fn admin_get_referral(&self, referral_id: u64) -> DbResult { + Ok(sqlx::query_as("SELECT * FROM referral WHERE id = ?") + .bind(referral_id) + .fetch_one(&self.db) + .await?) + } + async fn admin_list_ip_ranges( &self, limit: u64, diff --git a/lnvps_e2e/src/admin_api.rs b/lnvps_e2e/src/admin_api.rs index 38fc2a12..cfb7c27a 100644 --- a/lnvps_e2e/src/admin_api.rs +++ b/lnvps_e2e/src/admin_api.rs @@ -150,7 +150,10 @@ mod tests { let expect_vms = has_vms == "true"; for u in &data.data { let has = u["vm_count"].as_u64().unwrap_or(0) > 0; - assert_eq!(has, expect_vms, "has_vms={has_vms} returned mismatched user"); + assert_eq!( + has, expect_vms, + "has_vms={has_vms} returned mismatched user" + ); } } diff --git a/lnvps_e2e/src/db.rs b/lnvps_e2e/src/db.rs index a33693ac..a66fc66d 100644 --- a/lnvps_e2e/src/db.rs +++ b/lnvps_e2e/src/db.rs @@ -360,7 +360,7 @@ pub async fn insert_referral( lightning_address: Option<&str>, ) -> anyhow::Result { let res: (u64,) = sqlx::query_as( - "INSERT INTO referral (user_id, code, use_nwc, lightning_address) VALUES (?, ?, 0, ?) RETURNING id", + "INSERT INTO referral (user_id, code, mode, lightning_address) VALUES (?, ?, 0, ?) RETURNING id", ) .bind(user_id) .bind(code) diff --git a/lnvps_e2e/src/lifecycle.rs b/lnvps_e2e/src/lifecycle.rs index 1a793038..691f2f92 100644 --- a/lnvps_e2e/src/lifecycle.rs +++ b/lnvps_e2e/src/lifecycle.rs @@ -278,24 +278,25 @@ mod tests { let referrer_keys = nostr::Keys::generate(); let referrer = user_client_with_keys(referrer_keys.clone()); - // Referrer signs up for referral program (use_nwc requires NWC - // configured, lightning_address requires resolution — neither - // works in a local test, so test the error handling first) + // Referrer signs up for referral program. Default mode is + // lightning_address (requires a resolvable address) and nwc mode + // requires an NWC connection — neither works in a local test, so test + // the error handling first. let resp = referrer - .post_auth("/api/v1/referral", &serde_json::json!({"use_nwc": false})) + .post_auth("/api/v1/referral", &serde_json::json!({})) .await .unwrap(); - // Should fail: no payout method specified + // Should fail: lightning_address required for default mode assert_ne!(resp.status(), StatusCode::OK); eprintln!("Referral signup without payout method correctly rejected"); - // Sign up with use_nwc=true — will fail because no NWC configured + // Sign up with mode=nwc — will fail because no NWC configured let resp = referrer - .post_auth("/api/v1/referral", &serde_json::json!({"use_nwc": true})) + .post_auth("/api/v1/referral", &serde_json::json!({"mode": "nwc"})) .await .unwrap(); assert_ne!(resp.status(), StatusCode::OK); - eprintln!("Referral signup with use_nwc but no NWC string correctly rejected"); + eprintln!("Referral signup with mode=nwc but no NWC string correctly rejected"); // Insert referral directly via DB (bypasses lightning address validation) let ref_code = format!("E2E{}", &format!("{ts}")[..5]); @@ -422,8 +423,11 @@ mod tests { false, "PATCH should disable auto-renewal" ); - // Another user cannot patch someone else's subscription - let forbidden = admin + // Another user cannot patch someone else's subscription. Use a second + // *user* client (the referrer) — the admin client targets the admin API + // server, which doesn't mount the user subscription route, so it would + // return 404 (route not found) rather than exercising the ownership check. + let forbidden = referrer .patch_auth( &format!("/api/v1/subscriptions/{sub_id}"), &serde_json::json!({ "auto_renewal_enabled": true }), diff --git a/work/referral-program-apis.md b/work/referral-program-apis.md new file mode 100644 index 00000000..1fd2b20c --- /dev/null +++ b/work/referral-program-apis.md @@ -0,0 +1,126 @@ +# Referral Program API Completion + +**Status:** complete +**Started:** 2026-07-18 +**Last updated:** 2026-07-18 (all 4 PRs complete) + +## Goal + +Complete the referral program so it is fully operable end-to-end: a configurable +commission (percentage of the referred VM's first payment), admin management APIs +(list/inspect referrers, earnings, payouts; manual payout + reconcile), automated +payout processing (pay referrers via Lightning address / NWC, capture preimage), +and the missing user-facing endpoints. Delivered as a sequence of L-or-smaller +PRs, one increment at a time. + +## Findings + +Current state (as of investigation): + +- **User API** `lnvps_api/src/api/referral.rs`: `POST` enroll, `GET` state, + `PATCH` payout prefs. State exposes per-currency `earned`, `payouts`, and + success/failed counts. No `DELETE`, no per-referral VM detail list. +- **Attribution**: `vm.ref_code` set at order time (`provisioner/vm.rs`). + Earnings basis = first paid subscription payment per referred VM + (`list_referral_usage` in `lnvps_db/src/mysql.rs:2980`). +- **DB layer** (`lnvps_db/src/lib.rs:790+`, `model.rs:1310+`): `Referral`, + `ReferralPayout` (has `is_paid`, `invoice`, `pre_image`), `ReferralCostUsage`, + and `insert/update_referral_payout`. +- **DEAD CODE**: `insert_referral_payout` / `update_referral_payout` are never + called in production — no accrual, no worker payout job, `pre_image` unused, + `payouts` is always empty in practice. +- **Admin**: only `GET /api/admin/v1/reports/referral-usage/time-series` + (`admin/reports.rs`). No referral CRUD, no payout management. No dedicated + RBAC resource (report reuses `analytics::view`). +- **Config**: no commission rate / cap / threshold anywhere. `earned` currently + sums the *full* first payment. + +Decisions: + +- **Commission model**: configurable **percentage of the first payment** per + referred VM. The effective rate is **per-referrer with a company default**: + `referral.referral_rate` (nullable override on the referrer's entry) takes + precedence, else `company.referral_rate` (the referred VM's company). Payments + are company-scoped, so a referrer with no override uses each referred VM's + company default; an override applies to all of that referrer's referrals. +- Follow the project's explicit per-feature RBAC grant convention (grant new + `Referral` resource to `super_admin`; extend `read_only`/`admin` only if we + add it deliberately — mirror how existing resources were introduced). + +## Tasks + +### PR 1 — Commission config + flexible payout mode [M] +- [x] Replace `referral.use_nwc` boolean with a `ReferralPayoutMode` enum column + (`lightning_address` | `nwc` | `account_credit`, extensible). Migration + migrates use_nwc=1 -> nwc. API `mode` field replaces `use_nwc`; + `account_credit` reserved/rejected until implemented. +- [x] Migration: add `referral_rate FLOAT NOT NULL DEFAULT 0` to `company` + (default) and `referral_rate FLOAT NULL` to `referral` (per-user override). +- [x] `Company` + `Referral` models + all inserts/updates/selects (`lnvps_db`). +- [x] Admin company GET/PATCH expose + edit `referral_rate` (`admin/companies.rs`). +- [x] User `GET /api/v1/referral` exposes the per-referrer override (read-only; + admin-controlled — users cannot set their own commission). +- [x] Compute reward = first_payment * effective_rate% where + effective_rate = referral.referral_rate ?? company.referral_rate: surfaced + in `list_referral_usage` / `ReferralCostUsage.effective_rate`, applied in + `ApiReferralState` `earned` and the admin report (`effective_rate`,`commission`). +- [x] Tests (commission floor/0%, mode roundtrip, parse_payout_mode), docs + changelog. + +**PR1 committed:** `4048398`. Note: per-referrer override is set by admins (PR2), +not users. `referral.referral_rate` NULL = use company default. + +### PR 2 — Admin referral management APIs + RBAC [L] +- [x] Add `AdminResource::Referral = 25` (Display/FromStr/TryFrom/all + roundtrip + test); explicit permission migration `20260718002000` (grants super_admin). +- [x] DB: `admin_list_referrals` (paginated + search by code substring or 64-char + hex pubkey via SQL HEX()), `admin_get_referral`; extended + `update_referral_payout` to also set invoice; mock impls + test. +- [x] Endpoints (`admin/referrals.rs`): list, detail (earnings + payouts + + counts), PATCH commission override, list/create payout, PATCH reconcile + (is_paid/invoice/pre_image). +- [x] Sanitized (no NWC secrets). Tests, docs, changelog. + +**PR2 committed:** `4d19600`. Per-referrer override is set here via +`PATCH /api/admin/v1/referrals/{id}`. + +### PR 3 — Automated payout processing (worker) [L] +- [x] Accrual: owed BTC = sum(commission on first payments) − sum(existing BTC + payouts, paid + reserved). Only BTC is auto-paid (Lightning settles sats); + fiat commission left for manual admin payout. +- [x] Config: opt-in `referral` settings section (`min-payout-sats`, default + 1000). Absent = automated payouts disabled. Job scheduled hourly, gated on config. +- [x] Dedicated `ReferralPayoutHandler` (`lnvps_api/src/referral/mod.rs`) — + **not** in SubscriptionHandler. Reserve-then-pay (delete reservation on + failure) so no double-pay; LNURL-pay + NWC make_invoice; captures pre_image; + notifies referrer. New DB base methods `list_all_referrals` / + `delete_referral_payout`; `update_referral_payout` also persists invoice. +- [x] Expose `pre_image` (hex) in `ApiReferralPayout`. Tests (payable math), + docs, changelog. + +**PR3 committed:** `8980e48`. Note: enabled lnurl-rs `async-https-native` +feature; new `WorkJob::ProcessReferralPayouts`; `Worker::new` gained a `node` param. + +### PR 4 — User API extras [M] +- [x] `DELETE /api/v1/referral` (leave program). Blocks on pending payout (409) + and on paid payout history (retained for accounting). New base DB method + `delete_referral` + mock test. +- [x] `GET /api/v1/referral/usage` — per-referred-VM breakdown (vm_id, first + payment, currency, effective_rate, commission). +- [x] `pre_image` already surfaced in payout records (PR3). Tests, docs, changelog. + +**PR4 committed:** `b5dac33`. + +## Summary + +All four increments are merged: PR1 `4048398` (commission rate + payout mode), +PR2 `4d19600` (admin management + RBAC), PR3 `8980e48` (automated payout worker), +PR4 `b5dac33` (leave program + per-VM usage). The referral program is now +complete end-to-end: configurable per-referrer/company commission, admin +management, automated BTC Lightning payouts (opt-in), and full user self-service. + +## Notes + +- Currency: earnings/payouts are per-currency (referred VMs may span companies / + currencies). Payout via Lightning implies BTC; conversion policy for non-BTC + earned balances must be decided in PR 3. +- Keep one writer per PR; do not start PR N+1 until PR N is committed.