From 40483983b2396e2a781d8e6bd777df7e53a79933 Mon Sep 17 00:00:00 2001 From: v0l Date: Thu, 16 Jul 2026 12:13:02 +0100 Subject: [PATCH 01/11] feat(referral): commission rate + flexible payout mode (PR1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commission model — percentage of a referred VM's first payment: - company.referral_rate (default) + referral.referral_rate (per-referrer override, admin-controlled); effective rate = override ?? company default - earned amounts + admin referral report now report commission = amount * rate% - admin company create/update/get expose referral_rate Payout mode — replace referral.use_nwc bool with an extensible ReferralPayoutMode enum column (lightning_address | nwc | account_credit): - POST/PATCH/GET /api/v1/referral use 'mode' instead of 'use_nwc' - account_credit reserved for a future account-balance payout (rejected now) - migration maps use_nwc=1 -> nwc Includes migrations, model/mock/e2e updates, tests, docs + changelog. Part of work/referral-program-apis.md (PR1 of 4). --- ADMIN_API_ENDPOINTS.md | 21 ++- API_CHANGELOG.md | 8 ++ lnvps_api/src/api/referral.rs | 129 +++++++++++++----- lnvps_api_admin/src/admin/companies.rs | 7 + lnvps_api_admin/src/admin/model.rs | 8 ++ lnvps_api_admin/src/admin/reports.rs | 25 ++-- lnvps_api_admin/src/bin/generate_demo_data.rs | 3 + lnvps_api_common/src/mock.rs | 23 +++- ...0260718000000_referral_commission_rate.sql | 17 +++ .../20260718001000_referral_payout_mode.sql | 15 ++ lnvps_db/src/model.rs | 115 +++++++++++++++- lnvps_db/src/mysql.rs | 28 ++-- lnvps_e2e/src/db.rs | 2 +- lnvps_e2e/src/lifecycle.rs | 17 +-- work/referral-program-apis.md | 94 +++++++++++++ 15 files changed, 445 insertions(+), 67 deletions(-) create mode 100644 lnvps_db/migrations/20260718000000_referral_commission_rate.sql create mode 100644 lnvps_db/migrations/20260718001000_referral_payout_mode.sql create mode 100644 work/referral-program-apis.md diff --git a/ADMIN_API_ENDPOINTS.md b/ADMIN_API_ENDPOINTS.md index 7ddf6dd8..f349d057 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). @@ -3079,13 +3086,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..0fdcc0cf 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -8,6 +8,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- **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/lnvps_api/src/api/referral.rs b/lnvps_api/src/api/referral.rs index 4ad3066f..0df2a58c 100644 --- a/lnvps_api/src/api/referral.rs +++ b/lnvps_api/src/api/referral.rs @@ -9,7 +9,7 @@ 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; @@ -27,10 +27,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 +45,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 +57,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, } @@ -101,10 +109,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() @@ -125,11 +133,10 @@ impl ApiReferralState { /// 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 +149,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 +252,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 +280,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,11 +314,23 @@ 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; + } + + // The resulting configuration must have the details its mode needs. + if referral.mode == ReferralPayoutMode::LightningAddress + && referral + .lightning_address + .as_deref() + .map(str::trim) + .unwrap_or("") + .is_empty() + { + return ApiData::err("lightning_address is required when mode is 'lightning_address'"); } this.db.update_referral(&referral).await?; @@ -303,6 +348,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_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/model.rs b/lnvps_api_admin/src/admin/model.rs index 840bbddb..a2ddc5d8 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,6 +1883,7 @@ 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 } } 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..4796891e 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,7 +2480,8 @@ 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(()) } @@ -2516,6 +2518,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 +2556,7 @@ impl LNVpsDbBase for MockDb { currency: first.currency.clone(), rate: first.rate, base_currency: "EUR".to_string(), + effective_rate, }); } } 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/src/model.rs b/lnvps_db/src/model.rs index cd3d4baa..fd586c8e 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)] @@ -2042,6 +2104,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 { diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index d17d3509..e480b15b 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -2924,21 +2924,25 @@ 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 = ?") + sqlx::query( + "UPDATE referral SET lightning_address = ?, mode = ?, referral_rate = ? WHERE id = ?", + ) .bind(&referral.lightning_address) - .bind(referral.use_nwc) + .bind(referral.mode) + .bind(referral.referral_rate) .bind(referral.id) .execute(&self.db) .await?; @@ -2985,7 +2989,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 +3004,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 +4343,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 +4357,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 +4368,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 +4382,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 +4479,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 +4494,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 <= ? 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..4c0bfd88 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]); diff --git a/work/referral-program-apis.md b/work/referral-program-apis.md new file mode 100644 index 00000000..42b60966 --- /dev/null +++ b/work/referral-program-apis.md @@ -0,0 +1,94 @@ +# Referral Program API Completion + +**Status:** in-progress +**Started:** 2026-07-18 +**Last updated:** 2026-07-18 (PR1 in progress: commission rate + payout mode) + +## 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. +- [ ] Migration: add `referral_rate FLOAT NOT NULL DEFAULT 0` to `company` + (default) and `referral_rate FLOAT NULL` to `referral` (per-user override). +- [ ] `Company` + `Referral` models + all inserts/updates/selects (`lnvps_db`). +- [ ] Admin company GET/PATCH expose + edit `referral_rate` (`admin/companies.rs`). +- [ ] User `GET`/`PATCH /api/v1/referral` expose + edit the per-user override + (`referral_rate`, null = use company default). +- [ ] Compute reward = first_payment * effective_rate% where + effective_rate = referral.referral_rate ?? company.referral_rate: surface + both the company rate and the referrer override in `list_referral_usage` / + `ReferralCostUsage`, apply in `ApiReferralState` `earned` and the admin report. +- [ ] Tests (override wins, company default fallback, 0% default), docs + changelog. + +### PR 2 — Admin referral management APIs + RBAC [L] +- [ ] Add `AdminResource::Referral = 25`; explicit permission migration. +- [ ] DB: `admin_list_referrals` (paginated + search by code/user), `admin_get_referral`, + `admin_list_referral_payouts`, earnings aggregation per referral. +- [ ] Endpoints: list referrals, get referral detail (earnings + payouts), + create manual payout record, mark payout paid / reconcile (with external ref). +- [ ] Sanitize responses (no NWC secrets). Tests, docs, changelog. + +### PR 3 — Automated payout processing (worker) [L] +- [ ] Accrual: owed = sum(reward per referred VM first payment) − sum(existing payouts) per currency. +- [ ] Config: min payout threshold + schedule; expose in settings. +- [ ] Worker job: create `ReferralPayout`, pay via Lightning address (LNURL-pay) + or NWC, capture `pre_image`, mark `is_paid`; failure handling + retries + notifications. +- [ ] Expose `pre_image` (hex) in `ApiReferralPayout`. Tests, docs, changelog. + +### PR 4 — User API extras [M] +- [ ] `DELETE /api/v1/referral` (leave program; guard pending payouts). +- [ ] `GET /api/v1/referral/usage` — per-referred-VM breakdown. +- [ ] Refine payout status surfacing. Tests, docs, changelog. + +## 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. From 30e8625ccf75a84eb8cfa4905f221083447ded65 Mon Sep 17 00:00:00 2001 From: v0l Date: Thu, 16 Jul 2026 12:13:20 +0100 Subject: [PATCH 02/11] docs: mark referral PR1 complete in work file --- work/referral-program-apis.md | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/work/referral-program-apis.md b/work/referral-program-apis.md index 42b60966..d78fbf5b 100644 --- a/work/referral-program-apis.md +++ b/work/referral-program-apis.md @@ -54,17 +54,20 @@ Decisions: (`lightning_address` | `nwc` | `account_credit`, extensible). Migration migrates use_nwc=1 -> nwc. API `mode` field replaces `use_nwc`; `account_credit` reserved/rejected until implemented. -- [ ] Migration: add `referral_rate FLOAT NOT NULL DEFAULT 0` to `company` +- [x] Migration: add `referral_rate FLOAT NOT NULL DEFAULT 0` to `company` (default) and `referral_rate FLOAT NULL` to `referral` (per-user override). -- [ ] `Company` + `Referral` models + all inserts/updates/selects (`lnvps_db`). -- [ ] Admin company GET/PATCH expose + edit `referral_rate` (`admin/companies.rs`). -- [ ] User `GET`/`PATCH /api/v1/referral` expose + edit the per-user override - (`referral_rate`, null = use company default). -- [ ] Compute reward = first_payment * effective_rate% where - effective_rate = referral.referral_rate ?? company.referral_rate: surface - both the company rate and the referrer override in `list_referral_usage` / - `ReferralCostUsage`, apply in `ApiReferralState` `earned` and the admin report. -- [ ] Tests (override wins, company default fallback, 0% default), docs + changelog. +- [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] - [ ] Add `AdminResource::Referral = 25`; explicit permission migration. From 4d19600d7d588795ffaf7aa9363e6a02fac89ddd Mon Sep 17 00:00:00 2001 From: v0l Date: Thu, 16 Jul 2026 12:25:36 +0100 Subject: [PATCH 03/11] feat(referral): admin management APIs + RBAC (PR2) - Add AdminResource::Referral = 25 (Display/FromStr/TryFrom/all) + explicit permission migration granting super_admin (per-feature convention) - DB: admin_list_referrals (paginated; search by code substring or 64-char hex pubkey via SQL HEX()), admin_get_referral; update_referral_payout also sets invoice; mock impls + tests - New admin/referrals.rs endpoints: * GET /api/admin/v1/referrals list (paginated, search) * GET /api/admin/v1/referrals/{id} detail (earned + payouts + counts) * PATCH /api/admin/v1/referrals/{id} set/clear per-referrer commission override * GET/POST /api/admin/v1/referrals/{id}/payouts list / create manual payout * PATCH .../payouts/{payout_id} mark paid / reconcile (invoice, preimage) - Responses never expose NWC secrets. Docs + changelog. Part of work/referral-program-apis.md (PR2 of 4). --- ADMIN_API_ENDPOINTS.md | 145 ++++++++++ API_CHANGELOG.md | 3 + lnvps_api_admin/src/admin/mod.rs | 2 + lnvps_api_admin/src/admin/model.rs | 112 ++++++++ lnvps_api_admin/src/admin/referrals.rs | 254 ++++++++++++++++++ lnvps_api_common/src/mock.rs | 75 ++++++ ...260718002000_referral_rbac_permissions.sql | 16 ++ lnvps_db/src/admin.rs | 14 + lnvps_db/src/model.rs | 19 ++ lnvps_db/src/mysql.rs | 79 +++++- work/referral-program-apis.md | 18 +- 11 files changed, 719 insertions(+), 18 deletions(-) create mode 100644 lnvps_api_admin/src/admin/referrals.rs create mode 100644 lnvps_db/migrations/20260718002000_referral_rbac_permissions.sql diff --git a/ADMIN_API_ENDPOINTS.md b/ADMIN_API_ENDPOINTS.md index f349d057..61060048 100644 --- a/ADMIN_API_ENDPOINTS.md +++ b/ADMIN_API_ENDPOINTS.md @@ -3004,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 diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index 0fdcc0cf..cf8181d0 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- **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`. 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 a2ddc5d8..e63871fd 100644 --- a/lnvps_api_admin/src/admin/model.rs +++ b/lnvps_api_admin/src/admin/model.rs @@ -1889,6 +1889,118 @@ impl From for AdminCompanyInfo { } } +// 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_common/src/mock.rs b/lnvps_api_common/src/mock.rs index 4796891e..5ba34787 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -2500,6 +2500,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(()) @@ -3117,6 +3118,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, @@ -3466,6 +3501,46 @@ mod tests { use super::*; use lnvps_db::{IntervalType, LNVpsDbBase, SubscriptionPaymentType}; + /// 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_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/model.rs b/lnvps_db/src/model.rs index fd586c8e..4b7c195b 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -1660,6 +1660,7 @@ pub enum AdminResource { DnsServer = 22, UserPaymentMethod = 23, ResourceCost = 24, + Referral = 25, } /// Actions that can be performed on administrative resources @@ -1700,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"), } } } @@ -1734,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)), } } @@ -1769,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)), } } @@ -1803,6 +1807,7 @@ impl AdminResource { AdminResource::DnsServer, AdminResource::UserPaymentMethod, AdminResource::ResourceCost, + AdminResource::Referral, ] } } @@ -2238,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 e480b15b..e3b594ce 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -2940,12 +2940,12 @@ impl LNVpsDbBase for LNVpsDbMysql { 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?; + .bind(&referral.lightning_address) + .bind(referral.mode) + .bind(referral.referral_rate) + .bind(referral.id) + .execute(&self.db) + .await?; Ok(()) } @@ -2963,12 +2963,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(()) } @@ -4518,6 +4521,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/work/referral-program-apis.md b/work/referral-program-apis.md index d78fbf5b..02cf4aa5 100644 --- a/work/referral-program-apis.md +++ b/work/referral-program-apis.md @@ -70,12 +70,18 @@ Decisions: not users. `referral.referral_rate` NULL = use company default. ### PR 2 — Admin referral management APIs + RBAC [L] -- [ ] Add `AdminResource::Referral = 25`; explicit permission migration. -- [ ] DB: `admin_list_referrals` (paginated + search by code/user), `admin_get_referral`, - `admin_list_referral_payouts`, earnings aggregation per referral. -- [ ] Endpoints: list referrals, get referral detail (earnings + payouts), - create manual payout record, mark payout paid / reconcile (with external ref). -- [ ] Sanitize responses (no NWC secrets). Tests, docs, changelog. +- [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:** (pending). Per-referrer override is set here via +`PATCH /api/admin/v1/referrals/{id}`. ### PR 3 — Automated payout processing (worker) [L] - [ ] Accrual: owed = sum(reward per referred VM first payment) − sum(existing payouts) per currency. From 90b20d11087b7b650b04b41c3bfe9f686cc680e9 Mon Sep 17 00:00:00 2001 From: v0l Date: Thu, 16 Jul 2026 12:25:41 +0100 Subject: [PATCH 04/11] docs: record referral PR2 commit hash --- work/referral-program-apis.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/work/referral-program-apis.md b/work/referral-program-apis.md index 02cf4aa5..7058ae08 100644 --- a/work/referral-program-apis.md +++ b/work/referral-program-apis.md @@ -80,7 +80,7 @@ not users. `referral.referral_rate` NULL = use company default. (is_paid/invoice/pre_image). - [x] Sanitized (no NWC secrets). Tests, docs, changelog. -**PR2 committed:** (pending). Per-referrer override is set here via +**PR2 committed:** `4d19600`. Per-referrer override is set here via `PATCH /api/admin/v1/referrals/{id}`. ### PR 3 — Automated payout processing (worker) [L] From 8980e486b7e28694999298cefdfaee4a2c1a6e6f Mon Sep 17 00:00:00 2001 From: v0l Date: Thu, 16 Jul 2026 13:23:12 +0100 Subject: [PATCH 05/11] feat(referral): automated commission payouts worker (PR3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dedicated ReferralPayoutHandler (lnvps_api/src/referral/mod.rs) — kept out of SubscriptionHandler; referrals are not a billing concern. - Accrual: owed BTC = commission on referred VMs' first payments minus existing BTC payouts (paid + reserved). Only BTC is auto-paid (Lightning settles sats); fiat commission accrues for manual admin payout. - Reserve-then-pay: insert an unpaid payout first, pay it, mark paid + record preimage; delete the reservation on failure so no double-pay and the balance can retry. Per-referrer errors don't abort the batch; referrer is notified. - Payout methods: LNURL-pay (lightning_address) or NWC make_invoice (nwc); node.pay_invoice settles it. account_credit rejected. - Opt-in via new 'referral' settings section (min-payout-sats, default 1000); WorkJob::ProcessReferralPayouts scheduled hourly only when configured. - DB base methods list_all_referrals / delete_referral_payout; update_referral_payout now also persists invoice. ApiReferralPayout exposes pre_image (hex). Enabled lnurl-rs async-https-native. Worker::new gained node. - Tests (payable math), config example, changelog. Part of work/referral-program-apis.md (PR3 of 4). --- API_CHANGELOG.md | 3 + Cargo.lock | 20 +++ lnvps_api/Cargo.toml | 2 +- lnvps_api/config.yaml | 5 + lnvps_api/src/api/referral.rs | 3 + lnvps_api/src/bin/api.rs | 8 + lnvps_api/src/lib.rs | 1 + lnvps_api/src/referral/mod.rs | 284 +++++++++++++++++++++++++++++++ lnvps_api/src/settings.rs | 19 +++ lnvps_api/src/worker.rs | 29 +++- lnvps_api_common/src/mock.rs | 13 ++ lnvps_api_common/src/work/mod.rs | 3 + lnvps_db/src/lib.rs | 7 + lnvps_db/src/mysql.rs | 14 ++ work/referral-program-apis.md | 20 ++- 15 files changed, 423 insertions(+), 8 deletions(-) create mode 100644 lnvps_api/src/referral/mod.rs diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index cf8181d0..b61ee28d 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- **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. 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/referral.rs b/lnvps_api/src/api/referral.rs index 0df2a58c..212c12ac 100644 --- a/lnvps_api/src/api/referral.rs +++ b/lnvps_api/src/api/referral.rs @@ -72,6 +72,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 { @@ -83,6 +85,7 @@ impl From for ApiReferralPayout { created: p.created, is_paid: p.is_paid, invoice: p.invoice, + pre_image: p.pre_image.map(hex::encode), } } } 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/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_common/src/mock.rs b/lnvps_api_common/src/mock.rs index 5ba34787..ef90d386 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -2486,6 +2486,19 @@ impl LNVpsDbBase for MockDb { 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; 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/src/lib.rs b/lnvps_db/src/lib.rs index 6f435ba6..0d946aa7 100644 --- a/lnvps_db/src/lib.rs +++ b/lnvps_db/src/lib.rs @@ -802,12 +802,19 @@ pub trait LNVpsDbBase: Send + Sync { /// Update an existing referral entry async fn update_referral(&self, referral: &Referral) -> 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/mysql.rs b/lnvps_db/src/mysql.rs index e3b594ce..3649d163 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -2949,6 +2949,20 @@ impl LNVpsDbBase for LNVpsDbMysql { 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(()) + } + 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", diff --git a/work/referral-program-apis.md b/work/referral-program-apis.md index 7058ae08..9fbbab71 100644 --- a/work/referral-program-apis.md +++ b/work/referral-program-apis.md @@ -84,11 +84,21 @@ not users. `referral.referral_rate` NULL = use company default. `PATCH /api/admin/v1/referrals/{id}`. ### PR 3 — Automated payout processing (worker) [L] -- [ ] Accrual: owed = sum(reward per referred VM first payment) − sum(existing payouts) per currency. -- [ ] Config: min payout threshold + schedule; expose in settings. -- [ ] Worker job: create `ReferralPayout`, pay via Lightning address (LNURL-pay) - or NWC, capture `pre_image`, mark `is_paid`; failure handling + retries + notifications. -- [ ] Expose `pre_image` (hex) in `ApiReferralPayout`. Tests, docs, changelog. +- [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:** (pending). Note: enabled lnurl-rs `async-https-native` +feature; new `WorkJob::ProcessReferralPayouts`; `Worker::new` gained a `node` param. ### PR 4 — User API extras [M] - [ ] `DELETE /api/v1/referral` (leave program; guard pending payouts). From 621dbbf9040524e488359dbabadff9b3251da1f2 Mon Sep 17 00:00:00 2001 From: v0l Date: Thu, 16 Jul 2026 13:23:16 +0100 Subject: [PATCH 06/11] docs: record referral PR3 commit hash --- work/referral-program-apis.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/work/referral-program-apis.md b/work/referral-program-apis.md index 9fbbab71..db237eee 100644 --- a/work/referral-program-apis.md +++ b/work/referral-program-apis.md @@ -97,7 +97,7 @@ not users. `referral.referral_rate` NULL = use company default. - [x] Expose `pre_image` (hex) in `ApiReferralPayout`. Tests (payable math), docs, changelog. -**PR3 committed:** (pending). Note: enabled lnurl-rs `async-https-native` +**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] From 32ee938c79e2b07577dbbfb631497867564980a1 Mon Sep 17 00:00:00 2001 From: v0l Date: Thu, 16 Jul 2026 13:27:53 +0100 Subject: [PATCH 07/11] style: apply cargo fmt across workspace --- lnvps_api/src/api/mod.rs | 5 ++++- lnvps_api/src/subscription/mod.rs | 6 +++--- lnvps_api_common/src/pricing.rs | 12 ++++++++++-- lnvps_e2e/src/admin_api.rs | 5 ++++- 4 files changed, 21 insertions(+), 7 deletions(-) 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/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_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_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" + ); } } From b5dac3347918f24bb245fc4343ea3341c9e754ed Mon Sep 17 00:00:00 2001 From: v0l Date: Thu, 16 Jul 2026 13:37:05 +0100 Subject: [PATCH 08/11] feat(referral): leave program + per-VM usage (PR4) - DELETE /api/v1/referral: leave the program. Blocked (409) while a payout is pending, and when paid payout history exists (retained for accounting; the referral_payout FK has no cascade). New base DB method delete_referral. - GET /api/v1/referral/usage: per-referred-VM breakdown (vm_id, first payment amount, currency, effective_rate, commission). - Mock test for delete_referral/list_all_referrals; docs + changelog. Completes work/referral-program-apis.md (PR4 of 4). --- API_CHANGELOG.md | 4 ++ lnvps_api/src/api/referral.rs | 93 ++++++++++++++++++++++++++++++++--- lnvps_api_common/src/mock.rs | 31 ++++++++++++ lnvps_db/src/lib.rs | 4 ++ lnvps_db/src/mysql.rs | 8 +++ work/referral-program-apis.md | 11 +++-- 6 files changed, 142 insertions(+), 9 deletions(-) diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index b61ee28d..027a0846 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -8,6 +8,10 @@ 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). diff --git a/lnvps_api/src/api/referral.rs b/lnvps_api/src/api/referral.rs index 212c12ac..858a0048 100644 --- a/lnvps_api/src/api/referral.rs +++ b/lnvps_api/src/api/referral.rs @@ -14,12 +14,15 @@ 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 @@ -133,6 +136,23 @@ 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 { @@ -341,6 +361,67 @@ async fn v1_update_referral( 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::*; diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index ef90d386..8785bdc3 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -2486,6 +2486,12 @@ impl LNVpsDbBase for MockDb { 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(); @@ -3514,6 +3520,31 @@ 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] diff --git a/lnvps_db/src/lib.rs b/lnvps_db/src/lib.rs index 0d946aa7..a6d0447c 100644 --- a/lnvps_db/src/lib.rs +++ b/lnvps_db/src/lib.rs @@ -802,6 +802,10 @@ 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>; diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index 3649d163..14f1be74 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -2949,6 +2949,14 @@ impl LNVpsDbBase for LNVpsDbMysql { 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) diff --git a/work/referral-program-apis.md b/work/referral-program-apis.md index db237eee..c95813ec 100644 --- a/work/referral-program-apis.md +++ b/work/referral-program-apis.md @@ -101,9 +101,14 @@ not users. `referral.referral_rate` NULL = use company default. feature; new `WorkJob::ProcessReferralPayouts`; `Worker::new` gained a `node` param. ### PR 4 — User API extras [M] -- [ ] `DELETE /api/v1/referral` (leave program; guard pending payouts). -- [ ] `GET /api/v1/referral/usage` — per-referred-VM breakdown. -- [ ] Refine payout status surfacing. Tests, docs, changelog. +- [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:** (pending). ## Notes From 2d60cf9c28afa6afd1906c968ab3e8bbea8356ba Mon Sep 17 00:00:00 2001 From: v0l Date: Thu, 16 Jul 2026 13:37:18 +0100 Subject: [PATCH 09/11] docs: mark referral program work file complete --- work/referral-program-apis.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/work/referral-program-apis.md b/work/referral-program-apis.md index c95813ec..1fd2b20c 100644 --- a/work/referral-program-apis.md +++ b/work/referral-program-apis.md @@ -1,8 +1,8 @@ # Referral Program API Completion -**Status:** in-progress +**Status:** complete **Started:** 2026-07-18 -**Last updated:** 2026-07-18 (PR1 in progress: commission rate + payout mode) +**Last updated:** 2026-07-18 (all 4 PRs complete) ## Goal @@ -108,7 +108,15 @@ feature; new `WorkJob::ProcessReferralPayouts`; `Worker::new` gained a `node` pa payment, currency, effective_rate, commission). - [x] `pre_image` already surfaced in payout records (PR3). Tests, docs, changelog. -**PR4 committed:** (pending). +**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 From ac4765efda1371cf78d7637c15da66be6b9bb03a Mon Sep 17 00:00:00 2001 From: v0l Date: Thu, 16 Jul 2026 18:20:37 +0100 Subject: [PATCH 10/11] fix(referral): don't reject clearing lightning_address on PATCH The PATCH-time 'resulting config must be payable' check rejected clearing the lightning_address while in lightning_address mode, breaking a valid settings update (and the e2e lifecycle test). The payout worker already skips referrers whose method can't produce an invoice, so an incomplete config just defers payouts rather than losing them; signup still requires a valid method up-front. --- lnvps_api/src/api/referral.rs | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/lnvps_api/src/api/referral.rs b/lnvps_api/src/api/referral.rs index 858a0048..55f77717 100644 --- a/lnvps_api/src/api/referral.rs +++ b/lnvps_api/src/api/referral.rs @@ -344,18 +344,11 @@ async fn v1_update_referral( referral.mode = mode; } - // The resulting configuration must have the details its mode needs. - if referral.mode == ReferralPayoutMode::LightningAddress - && referral - .lightning_address - .as_deref() - .map(str::trim) - .unwrap_or("") - .is_empty() - { - return ApiData::err("lightning_address is required when mode is 'lightning_address'"); - } - + // 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()) From 727db2f45c7697b7e2d9ae2982cf9537f2864788 Mon Sep 17 00:00:00 2001 From: v0l Date: Thu, 16 Jul 2026 18:34:32 +0100 Subject: [PATCH 11/11] test(e2e): fix non-owner subscription PATCH check to use a user client The lifecycle test used the admin client (which targets the admin API server) to assert a non-owner cannot PATCH a subscription. That server doesn't mount the user route /api/v1/subscriptions/{id}, so it returned 404 (route not found) instead of exercising the ownership check (403). Use a second user client (the referrer) so the request hits the user API and the ownership guard. Pre-existing bug from the auto-renewal PATCH change; surfaced now because e2e only runs on PRs and this is the first e2e run since. --- lnvps_e2e/src/lifecycle.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lnvps_e2e/src/lifecycle.rs b/lnvps_e2e/src/lifecycle.rs index 4c0bfd88..691f2f92 100644 --- a/lnvps_e2e/src/lifecycle.rs +++ b/lnvps_e2e/src/lifecycle.rs @@ -423,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 }),