From a6605f4bce2087a9c7911c9d231954bf5d37af19 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 09:57:17 +0000 Subject: [PATCH 1/3] Initial plan From ab57e10990c394b3904a625f41e9c522684e3c88 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:08:26 +0000 Subject: [PATCH 2/3] feat: skip auto-deletion of unpaid VMs that have non-expired pending payments Agent-Logs-Url: https://github.com/LNVPS/api/sessions/b50172b1-15fd-4699-9b35-b2a5cd0674d0 Co-authored-by: v0l <1172179+v0l@users.noreply.github.com> --- lnvps_api/src/worker.rs | 122 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/lnvps_api/src/worker.rs b/lnvps_api/src/worker.rs index 6b82fa9..e669a4f 100644 --- a/lnvps_api/src/worker.rs +++ b/lnvps_api/src/worker.rs @@ -492,6 +492,36 @@ impl Worker { // Only proceed with deletion if the VM is still in the unpaid (new) state. match self.db.get_vm(vm.id).await { Ok(current_vm) if current_vm.created == current_vm.expires => { + // Skip deletion if the VM has any pending (unpaid, non-expired) payments. + match self.db.list_vm_payment(vm.id).await { + Ok(payments) => { + if payments + .iter() + .any(|p| !p.is_paid && p.expires > Utc::now()) + { + info!( + "VM {} has pending unpaid payments, skipping deletion", + vm.id + ); + continue; + } + } + Err(e) => { + error!( + "Failed to check payments for VM {} before deletion: {}", + vm.id, e + ); + self.queue_admin_notification( + format!( + "Failed to check payments for VM {} before deletion:\n{}", + vm.id, e + ), + Some(format!("VM {} Payment Check Failed", vm.id)), + ) + .await; + continue; + } + } info!("Deleting unpaid VM {}", vm.id); if let Err(e) = self.provisioner.delete_vm(vm.id).await { error!("Failed to delete unpaid VM {}: {}", vm.id, e); @@ -2472,4 +2502,96 @@ mod tests { ); Ok(()) } + + /// An unpaid VM (new state, older than 1 hour) with a non-expired pending payment must NOT + /// be deleted by check_vms. + #[tokio::test] + async fn test_check_vms_skips_unpaid_vm_with_pending_payment() -> Result<()> { + use lnvps_db::{EncryptedString, PaymentMethod, PaymentType, VmPayment}; + + let db = Arc::new(MockDb::default()); + let old = Utc::now().sub(TimeDelta::hours(2)); + let vm = add_vm_with_state(&db, old, old).await?; + let vm_id = vm.id; + + // Add a pending (unpaid, not-yet-expired) payment for this VM. + let payment = VmPayment { + id: vec![1u8; 32], + vm_id, + created: Utc::now(), + expires: Utc::now().add(TimeDelta::minutes(10)), + amount: 1000, + currency: "BTC".to_string(), + payment_method: PaymentMethod::Lightning, + payment_type: PaymentType::Renewal, + external_data: EncryptedString::from("test"), + external_id: None, + is_paid: false, + rate: 1.0, + time_value: 2592000, + tax: 0, + processing_fee: 0, + upgrade_params: None, + paid_at: None, + }; + db.insert_vm_payment(&payment).await?; + + let worker = setup_worker(db.clone()).await?; + worker.check_vms().await?; + + // VM must NOT be deleted because there is a pending payment. + let vms = db.vms.lock().await; + let deleted = vms.get(&vm_id).map(|v| v.deleted).unwrap_or(true); + assert!( + !deleted, + "Unpaid VM with a non-expired pending payment should not be deleted" + ); + Ok(()) + } + + /// An unpaid VM (new state, older than 1 hour) whose only payment is already expired must + /// still be deleted by check_vms. + #[tokio::test] + async fn test_check_vms_deletes_unpaid_vm_with_only_expired_payment() -> Result<()> { + use lnvps_db::{EncryptedString, PaymentMethod, PaymentType, VmPayment}; + + let db = Arc::new(MockDb::default()); + let old = Utc::now().sub(TimeDelta::hours(2)); + let vm = add_vm_with_state(&db, old, old).await?; + let vm_id = vm.id; + + // Add a payment whose invoice has already expired. + let payment = VmPayment { + id: vec![2u8; 32], + vm_id, + created: old, + expires: old.add(TimeDelta::minutes(10)), // expired long ago + amount: 1000, + currency: "BTC".to_string(), + payment_method: PaymentMethod::Lightning, + payment_type: PaymentType::Renewal, + external_data: EncryptedString::from("test"), + external_id: None, + is_paid: false, + rate: 1.0, + time_value: 2592000, + tax: 0, + processing_fee: 0, + upgrade_params: None, + paid_at: None, + }; + db.insert_vm_payment(&payment).await?; + + let worker = setup_worker(db.clone()).await?; + worker.check_vms().await?; + + // VM should be soft-deleted because the only payment is expired. + let vms = db.vms.lock().await; + let deleted = vms.get(&vm_id).map(|v| v.deleted).unwrap_or(false); + assert!( + deleted, + "Unpaid VM with only an expired payment should still be deleted" + ); + Ok(()) + } } From a0f4d8ba2c722b6ea7e1d036b322b9e5c56f31aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:19:35 +0000 Subject: [PATCH 3/3] refactor: add count_active_vm_payments DB fn and simplify check_vms deletion guard Agent-Logs-Url: https://github.com/LNVPS/api/sessions/5725fe22-9d28-4adb-8f8e-bd95204eabd9 Co-authored-by: v0l <1172179+v0l@users.noreply.github.com> --- lnvps_api/src/worker.rs | 41 +++++++++++------------------------- lnvps_api_common/src/mock.rs | 7 ++++++ lnvps_db/src/lib.rs | 3 +++ lnvps_db/src/mysql.rs | 9 ++++++++ 4 files changed, 31 insertions(+), 29 deletions(-) diff --git a/lnvps_api/src/worker.rs b/lnvps_api/src/worker.rs index e669a4f..e86ca02 100644 --- a/lnvps_api/src/worker.rs +++ b/lnvps_api/src/worker.rs @@ -492,35 +492,18 @@ impl Worker { // Only proceed with deletion if the VM is still in the unpaid (new) state. match self.db.get_vm(vm.id).await { Ok(current_vm) if current_vm.created == current_vm.expires => { - // Skip deletion if the VM has any pending (unpaid, non-expired) payments. - match self.db.list_vm_payment(vm.id).await { - Ok(payments) => { - if payments - .iter() - .any(|p| !p.is_paid && p.expires > Utc::now()) - { - info!( - "VM {} has pending unpaid payments, skipping deletion", - vm.id - ); - continue; - } - } - Err(e) => { - error!( - "Failed to check payments for VM {} before deletion: {}", - vm.id, e - ); - self.queue_admin_notification( - format!( - "Failed to check payments for VM {} before deletion:\n{}", - vm.id, e - ), - Some(format!("VM {} Payment Check Failed", vm.id)), - ) - .await; - continue; - } + if self + .db + .count_active_vm_payments(vm.id) + .await + .unwrap_or(0) + > 0 + { + info!( + "VM {} has pending unpaid payments, skipping deletion", + vm.id + ); + continue; } info!("Deleting unpaid VM {}", vm.id); if let Err(e) = self.provisioner.delete_vm(vm.id).await { diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index 471555e..92a16c1 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -852,6 +852,13 @@ impl LNVpsDbBase for MockDb { .cloned()) } + async fn count_active_vm_payments(&self, vm_id: u64) -> DbResult { + let p = self.payments.lock().await; + Ok(p.iter() + .filter(|p| p.vm_id == vm_id && !p.is_paid && p.expires > Utc::now()) + .count() as u64) + } + async fn list_custom_pricing(&self, _TB: u64) -> DbResult> { let p = self.custom_pricing.lock().await; Ok(p.values().cloned().collect()) diff --git a/lnvps_db/src/lib.rs b/lnvps_db/src/lib.rs index 4ebc226..891f029 100644 --- a/lnvps_db/src/lib.rs +++ b/lnvps_db/src/lib.rs @@ -286,6 +286,9 @@ pub trait LNVpsDbBase: Send + Sync { /// Return the most recently settled invoice async fn last_paid_invoice(&self) -> DbResult>; + /// Count active (unpaid, non-expired) payments for a VM + async fn count_active_vm_payments(&self, vm_id: u64) -> DbResult; + /// Return the list of active custom pricing models for a given region async fn list_custom_pricing(&self, region_id: u64) -> DbResult>; diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index fb8c49f..ccf624a 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -821,6 +821,15 @@ impl LNVpsDbBase for LNVpsDbMysql { .await?) } + async fn count_active_vm_payments(&self, vm_id: u64) -> DbResult { + let (count,): (i64,) = + sqlx::query_as("select count(*) from vm_payment where vm_id = ? and is_paid = false and expires > NOW()") + .bind(vm_id) + .fetch_one(&self.db) + .await?; + Ok(count as u64) + } + async fn list_custom_pricing(&self, region_id: u64) -> DbResult> { Ok( sqlx::query_as("select * from vm_custom_pricing where region_id = ?")