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 <[email protected]>
This commit is contained in:
4cuckandCursor committed 2026-10-13 20:04:50 +00:00
1 parent 1528e967e8
commit 8a9f095c31
2 files changed
+69 -15

No files matched your search

+65 -10
View File
@@ -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<i64> {
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<f64> {
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}}"#;
+4 -5
View File
@@ -245,22 +245,21 @@ async fn nowpayments_ipn(State(state): State<AppState>, headers: HeaderMap, body
let Ok(payload) = serde_json::from_slice::<Value>(&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()
}