diff --git a/backend/migrations/0001_init.sql b/backend/migrations/0001_init.sql index 480526a..ee561ed 100644 --- a/backend/migrations/0001_init.sql +++ b/backend/migrations/0001_init.sql @@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS invoice ( created_at INTEGER NOT NULL, paid_at INTEGER, voided_at INTEGER, + notified_at INTEGER, FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey) ); diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 71be8ef..103558e 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -18,6 +18,7 @@ const GRACE_PERIOD_SECS: i64 = 7 * 24 * 60 * 60; /// issued invoice is surfaced to the tenant in-app first (e.g. right after they /// create a relay), so we don't also nag by DM on the first dunning cycles. const FRESH_INVOICE_DM_GRACE_SECS: i64 = 24 * 60 * 60; +const MANUAL_PAYMENT_DM_INTERVAL_SECS: i64 = 12 * 24 * 60 * 60; const MANUAL_PAYMENT_DM: &str = "Payment is due for your relay subscription. Open the link below to review the invoice and pay by Lightning or card:"; const CHURN_DM: &str = "Your relay subscription is past due, so your relays have been paused. You can restore service any time by adding a payment method or paying an invoice from your dashboard:"; @@ -424,12 +425,19 @@ impl Billing { invoice: &Invoice, error: Option, ) -> Result<()> { - // If the invoice was just generated, give the user a chance to check the dashboard before nagging them. let now = chrono::Utc::now().timestamp(); + + // If the invoice was just generated, give the user a chance to check the dashboard before nagging them. if now - invoice.created_at < FRESH_INVOICE_DM_GRACE_SECS { return Ok(()); } + // The dunning poll runs hourly; avoid excessive reminder DMs. + if invoice.notified_at.is_some_and(|t| now - t < MANUAL_PAYMENT_DM_INTERVAL_SECS) { + return Ok(()); + } + + // Build the message let invoice_id = &invoice.id; let url_base = &env::get().app_url; let payment_url = format!("{url_base}/account?invoice={invoice_id}"); @@ -443,7 +451,11 @@ impl Billing { _ => base, }; - self.robot.send_dm(&tenant.pubkey, &dm_message).await + // Send via NIP 17 + self.robot.send_dm(&tenant.pubkey, &dm_message).await?; + + // Record the send to avoid spammy notifications. + command::mark_invoice_notified(invoice_id).await } // --- Bolt11 utils --- diff --git a/backend/src/command.rs b/backend/src/command.rs index a7379d5..cfb1fb5 100644 --- a/backend/src/command.rs +++ b/backend/src/command.rs @@ -422,6 +422,19 @@ pub async fn settle_invoice_via_stripe( .await } +/// Stamp an invoice with the time its manual-payment reminder DM was sent, so +/// dunning sends that DM once instead of on every hourly poll. +pub async fn mark_invoice_notified(invoice_id: &str) -> Result<()> { + let notified_at = chrono::Utc::now().timestamp(); + + sqlx::query("UPDATE invoice SET notified_at = ? WHERE id = ?") + .bind(notified_at) + .bind(invoice_id) + .execute(pool()) + .await?; + Ok(()) +} + // --- Bolt11 records --- pub async fn insert_bolt11( diff --git a/backend/src/models.rs b/backend/src/models.rs index ccb6801..377dd1e 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -133,6 +133,9 @@ pub struct Invoice { pub created_at: i64, pub paid_at: Option, pub voided_at: Option, + /// When the manual-payment reminder DM was sent for this invoice, or `None` if + /// it hasn't been sent in order to avoid duplicate reminders for the same invoice. + pub notified_at: Option, /// How the invoice was paid — `nwc`, `stripe`, or `oob` (out-of-band /// Lightning) — set when it is marked paid; `None` while open or void. pub method: Option,