From 8a9f095c31a0c6781a34786749cdc4e51919612a Mon Sep 17 00:00:00 2001 From: 4cuck <40539855+4cuck@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:04:50 +0000 Subject: [PATCH] Count a partial crypto donation as the amount that actually arrived. A NOWPayments invoice was being added at its full price even when only part of the coin had been sent. Co-authored-by: Cursor --- src/donations.rs | 75 ++++++++++++++++++++++++++++++++++++++------ src/routes/donate.rs | 9 +++--- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/src/donations.rs b/src/donations.rs index 3abb729..e2ac78a 100644 --- a/src/donations.rs +++ b/src/donations.rs @@ -101,20 +101,29 @@ impl DonationLedger { self.write(&file).await; } - /// Record a confirmed payment once. Later notices for the same provider - /// reference do not add the amount again. + /// Record a confirmed payment. A later notice for the same provider + /// reference replaces the amount, so a partial crypto payment can be + /// corrected when more arrives, without counting twice. pub async fn mark_paid(&self, id: &str, provider: &str, provider_ref: &str, cents: i64) { - if id.is_empty() || provider_ref.is_empty() || cents <= 0 { + if provider_ref.is_empty() || cents <= 0 { return; } let _guard = self.lock.lock().await; let mut file = self.read().await.unwrap_or_default(); - if file.entries.iter().any(|entry| { + if let Some(entry) = file.entries.iter_mut().find(|entry| { entry.status == "paid" && entry.provider == provider && entry.provider_ref == provider_ref }) { + if entry.cents == cents { + return; + } + entry.cents = cents; + if !id.is_empty() { + entry.id = id.to_owned(); + } + self.write(&file).await; return; } - if let Some(entry) = file.entries.iter_mut().find(|entry| entry.id == id) { + if let Some(entry) = file.entries.iter_mut().find(|entry| !id.is_empty() && entry.id == id) { entry.status = "paid".to_owned(); entry.provider = provider.to_owned(); entry.provider_ref = provider_ref.to_owned(); @@ -431,12 +440,29 @@ pub fn nowpayments_signature_ok(raw: &[u8], header: &str, secret: &str) -> bool }) } -pub fn nowpayments_is_paid(body: &Value) -> bool { - let status = body["payment_status"].as_str().unwrap_or("").to_ascii_lowercase(); - if status == "partially_paid" { - return false; +/// USD cents actually received. A partial crypto payment counts only the share +/// of the invoice that arrived, not the full `price_amount`. +pub fn nowpayments_received_cents(body: &Value) -> Option { + let status = body["payment_status"] + .as_str() + .unwrap_or("") + .to_ascii_lowercase(); + if matches!(status.as_str(), "failed" | "expired" | "refunded") { + return None; } - status == "finished" || status == "confirmed" || body["invoice_status"] == "finished" + let price = json_f64(&body["price_amount"])?; + let expected = json_f64(&body["pay_amount"])?; + let received = json_f64(&body["actually_paid"]).unwrap_or(0.0); + if price <= 0.0 || expected <= 0.0 || received <= 0.0 { + return None; + } + let ratio = (received / expected).clamp(0.0, 1.0); + let cents = if ratio >= 0.999 && matches!(status.as_str(), "finished" | "confirmed") { + (price * 100.0).round() as i64 + } else { + (ratio * price * 100.0).round() as i64 + }; + (cents > 0).then_some(cents) } pub async fn lzt_invoice( @@ -538,6 +564,13 @@ pub fn provider_ready(config: &DonationsConfig, provider: &str) -> bool { } } +fn json_f64(value: &Value) -> Option { + value + .as_f64() + .or_else(|| value.as_i64().map(|number| number as f64)) + .or_else(|| value.as_str().and_then(|text| text.parse().ok())) +} + fn urlencoding_path(value: &str) -> String { form_urlencoded::byte_serialize(value.as_bytes()).collect() } @@ -612,6 +645,28 @@ mod tests { assert!(!stripe_signature_ok(payload, &header, "whsec_other")); } + #[test] + fn partial_crypto_payment_counts_only_what_arrived() { + let body = serde_json::json!({ + "payment_status": "partially_paid", + "price_amount": 25, + "pay_amount": 0.4069642, + "actually_paid": 0.02107742, + "invoice_status": "finished" + }); + assert_eq!(nowpayments_received_cents(&body), Some(129)); + } + + #[test] + fn finished_crypto_payment_counts_the_invoice() { + let body = serde_json::json!({ + "payment_status": "finished", + "price_amount": 25, + "pay_amount": 0.4, + "actually_paid": 0.4 + }); + assert_eq!(nowpayments_received_cents(&body), Some(2500)); + } #[test] fn nowpayments_accepts_sorted_body() { let raw = br#"{"b":1,"a":{"d":2,"c":3}}"#; diff --git a/src/routes/donate.rs b/src/routes/donate.rs index 01af1e8..e8f2486 100644 --- a/src/routes/donate.rs +++ b/src/routes/donate.rs @@ -245,22 +245,21 @@ async fn nowpayments_ipn(State(state): State, headers: HeaderMap, body let Ok(payload) = serde_json::from_slice::(&body) else { return StatusCode::BAD_REQUEST.into_response(); }; - if !donations::nowpayments_is_paid(&payload) { + let Some(cents) = donations::nowpayments_received_cents(&payload) else { return StatusCode::OK.into_response(); - } + }; let id = payload["order_id"].as_str().unwrap_or(""); let reference = payload["payment_id"] .as_i64() .map(|value| value.to_string()) .or_else(|| payload["payment_id"].as_str().map(str::to_owned)) .unwrap_or_default(); - let cents = donations::dollars_value_to_cents(payload.get("price_amount").unwrap_or(&Value::Null)); - if donations::valid_donation_id(id) && cents > 0 && !reference.is_empty() { + if donations::valid_donation_id(id) && !reference.is_empty() { state .donations .mark_paid(id, "nowpayments", &reference, cents) .await; - tracing::info!(donation_id = id, cents, "nowpayments donation paid"); + tracing::info!(donation_id = id, cents, "nowpayments donation received"); } StatusCode::OK.into_response() }