diff --git a/backend/migrations/0001_init.sql b/backend/migrations/0001_init.sql index 9c37d27..480526a 100644 --- a/backend/migrations/0001_init.sql +++ b/backend/migrations/0001_init.sql @@ -49,6 +49,7 @@ CREATE TABLE IF NOT EXISTS invoice ( id TEXT PRIMARY KEY, tenant_pubkey TEXT NOT NULL, method TEXT CHECK (method IS NULL OR method IN ('nwc','stripe','oob')), + amount INTEGER NOT NULL, period_start INTEGER NOT NULL, period_end INTEGER NOT NULL, created_at INTEGER NOT NULL, @@ -110,5 +111,3 @@ CREATE INDEX IF NOT EXISTS idx_invoice_item_outstanding ON invoice_item (tenant_ CREATE UNIQUE INDEX IF NOT EXISTS idx_invoice_item_activity ON invoice_item (activity_id); CREATE INDEX IF NOT EXISTS idx_bolt11_invoice_created ON bolt11 (invoice_id, created_at); - -CREATE INDEX IF NOT EXISTS idx_intent_invoice ON intent (invoice_id); diff --git a/backend/src/billing.rs b/backend/src/billing.rs index d8b0ea6..0de6541 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -5,8 +5,7 @@ use crate::bitcoin; use crate::command; use crate::env; use crate::models::{ - Activity, Bolt11, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, Snapshot, - Tenant, + Activity, Bolt11, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, Snapshot, Tenant, }; use crate::query; use crate::robot::Robot; @@ -85,7 +84,8 @@ impl Billing { // A churned tenant with fresh billable activity is using the service // again: re-activate billing (and restore their relays) before billing it. if tenant.churned_at.is_some() && !activities.is_empty() { - self.reactivate_tenant(&tenant).await?; + let relays = query::list_relays_for_tenant(&tenant.pubkey).await?; + command::reactivate_tenant(&tenant.pubkey, &relays).await?; tenant.churned_at = None; } @@ -281,15 +281,20 @@ impl Billing { if !tenant.nwc_url.is_empty() { match self.attempt_payment_using_nwc(tenant, invoice).await { Ok(()) => return Ok(()), - Err(e) => { - let message = format!("{e}"); - command::set_tenant_nwc_error(&tenant.pubkey, &message).await?; - error_message = Some(message); - } + Err(e) => error_message = Some(format!("{e}")), } } - // 2. Payment method on file: if the tenant has one saved, charge it via Stripe. + // 2. Out-of-band lightning: catches partially failed NWC or manual payment + if let Some(bolt11) = query::get_bolt11_for_invoice(&invoice.id).await? + && bolt11.settled_at.is_none() + && self.wallet.is_settled(&bolt11.lnbc).await.unwrap_or(false) + { + command::settle_invoice_out_of_band(&bolt11.id, &invoice.id).await?; + return Ok(()); + } + + // 3. Payment method on file: if the tenant has one saved, charge it via Stripe. if let Some(payment_method) = self.stripe.get_saved_payment_method(&tenant.stripe_customer_id).await? { @@ -298,15 +303,11 @@ impl Billing { .await { Ok(()) => return Ok(()), - Err(e) => { - let message = format!("{e}"); - command::set_tenant_stripe_error(&tenant.pubkey, &message).await?; - error_message = error_message.or_else(|| Some(message)); - } + Err(e) => error_message = error_message.or_else(|| Some(format!("{e}"))), } } - // 3. Manual payment: DM a link to the in-app payment page for this invoice. + // 4. Manual payment: DM a link to the in-app payment page for this invoice. let summary = error_message.as_deref().and_then(summarize_error_message); if let Err(e) = self.attempt_payment_using_dm(tenant, invoice, summary).await { tracing::error!( @@ -320,26 +321,24 @@ impl Billing { } async fn attempt_payment_using_nwc(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> { - let nwc_url = env::get().decrypt(&tenant.nwc_url)?; - let tenant_wallet = Wallet::from_url(&nwc_url)?; - let bolt11 = self.ensure_bolt11(&invoice.id).await?; + let result: Result<()> = async { + let nwc_url = env::get().decrypt(&tenant.nwc_url)?; + let tenant_wallet = Wallet::from_url(&nwc_url)?; + let bolt11 = self.ensure_bolt11(invoice).await?; - match tenant_wallet.pay_invoice(bolt11.lnbc.clone()).await { - Ok(()) => { - command::clear_tenant_nwc_error(&tenant.pubkey).await?; - command::mark_bolt11_settled(&bolt11.id).await?; - command::mark_invoice_paid(&invoice.id, "nwc").await - } - Err(pay_error) => { - // The pay request errored, but the invoice may have been paid out of band. - if self.wallet.is_settled(&bolt11.lnbc).await.unwrap_or(false) { - command::mark_bolt11_settled(&bolt11.id).await?; - command::mark_invoice_paid(&invoice.id, "oob").await - } else { - Err(pay_error) - } - } + tenant_wallet.pay_invoice(bolt11.lnbc.clone()).await?; + + command::settle_invoice_via_nwc(&tenant.pubkey, &bolt11.id, &invoice.id).await } + .await; + + // Record the failure on the tenant (to warn them in the UI) but still + // surface it, so the cascade can fall through and summarize it in the DM. + if let Err(error) = &result { + command::set_tenant_nwc_error(&tenant.pubkey, &format!("{error}")).await?; + } + + result } async fn attempt_payment_using_stripe( @@ -348,24 +347,28 @@ impl Billing { invoice: &Invoice, payment_method_id: &str, ) -> Result<()> { - let amount = self.get_invoice_amount(&invoice.id).await?; + let result: Result<()> = async { + let intent_id = self + .stripe + .create_payment_intent( + &tenant.stripe_customer_id, + payment_method_id, + &invoice.id, + invoice.amount, + "usd", + ) + .await?; - // A decline or an off-session authentication demand comes back as Err, so - // the cascade falls back to the manual DM. - let intent_id = self - .stripe - .create_payment_intent( - &tenant.stripe_customer_id, - payment_method_id, - &invoice.id, - amount, - "usd", - ) - .await?; + command::settle_invoice_via_stripe(&tenant.pubkey, &intent_id, &invoice.id).await + } + .await; - command::insert_intent(&intent_id, &invoice.id).await?; - command::clear_tenant_stripe_error(&tenant.pubkey).await?; - command::mark_invoice_paid(&invoice.id, "stripe").await + // Record the failure on the tenant (to warn them in the UI) but still + // surface it, so the cascade can fall through and summarize it in the DM. + if let Err(error) = &result { + command::set_tenant_stripe_error(&tenant.pubkey, &format!("{error}")).await?; + } + result } async fn attempt_payment_using_dm( @@ -401,7 +404,8 @@ impl Billing { let now = chrono::Utc::now().timestamp(); if now - oldest.created_at >= GRACE_PERIOD_SECS { if tenant.churned_at.is_none() { - self.churn_tenant(tenant, now).await?; + let relays = query::list_relays_for_tenant(&tenant.pubkey).await?; + command::churn_tenant(&tenant.pubkey, now, &relays).await?; } return Ok(()); } @@ -413,50 +417,12 @@ impl Billing { Ok(()) } - /// Churn a tenant whose grace period has elapsed: record the churn, mark every - /// active relay delinquent, and void the unpaid invoices (we've given up - /// collecting them — re-activation never requires they be paid). - async fn churn_tenant(&self, tenant: &Tenant, now: i64) -> Result<()> { - command::set_tenant_churned_at(&tenant.pubkey, Some(now)).await?; - - for relay in query::list_relays_for_tenant(&tenant.pubkey).await? { - if relay.status == RELAY_STATUS_ACTIVE { - command::mark_relay_delinquent(&relay).await?; - } - } - - command::void_open_invoices(&tenant.pubkey).await - } - - /// Re-activate a churned tenant: clear the churn marker and restore every - /// delinquent relay to active. Any still-open invoices are voided so old debt - /// is never a precondition for coming back. - async fn reactivate_tenant(&self, tenant: &Tenant) -> Result<()> { - command::set_tenant_churned_at(&tenant.pubkey, None).await?; - - for relay in query::list_relays_for_tenant(&tenant.pubkey).await? { - if relay.status == RELAY_STATUS_DELINQUENT { - command::unmark_relay_delinquent(&relay).await?; - } - } - - command::void_open_invoices(&tenant.pubkey).await - } - - // --- Invoice utils --- - - pub async fn get_invoice_amount(&self, invoice_id: &str) -> Result { - let invoice_items = query::get_invoice_items_for_invoice(invoice_id).await?; - - Ok(invoice_items.iter().map(|item| item.amount).sum()) - } - // --- Bolt11 utils --- - pub async fn ensure_bolt11(&self, invoice_id: &str) -> Result { + pub async fn ensure_bolt11(&self, invoice: &Invoice) -> Result { let now = chrono::Utc::now().timestamp(); - if let Some(existing) = query::get_bolt11_for_invoice(invoice_id).await? + if let Some(existing) = query::get_bolt11_for_invoice(&invoice.id).await? && (existing.settled_at.is_none() || now < existing.expires_at) { return Ok(existing); @@ -464,11 +430,10 @@ impl Billing { let expiry: i64 = 3600; let info = "Relay subscription payment"; - let amount = self.get_invoice_amount(invoice_id).await?; - let msats = bitcoin::fiat_to_msats(amount, "usd").await?; + let msats = bitcoin::fiat_to_msats(invoice.amount, "usd").await?; let lnbc = self.wallet.make_invoice(msats, info, expiry as u64).await?; - command::insert_bolt11(invoice_id, &lnbc, msats as i64, now + expiry) + command::insert_bolt11(&invoice.id, &lnbc, msats as i64, now + expiry) .await? .ok_or_else(|| anyhow!("failed to insert bolt11")) } @@ -478,11 +443,11 @@ impl Billing { /// settled on the robot wallet, mark it paid and return the refreshed record; /// otherwise return it unchanged. Meant to run before presenting a payable /// invoice so we never hand back one that's already been paid. - pub async fn ensure_and_reconcile_bolt11(&self, invoice_id: &str) -> Result { - let bolt11 = self.ensure_bolt11(invoice_id).await?; + pub async fn ensure_and_reconcile_bolt11(&self, invoice: &Invoice) -> Result { + let bolt11 = self.ensure_bolt11(invoice).await?; if bolt11.settled_at.is_none() && self.wallet.is_settled(&bolt11.lnbc).await? { - command::mark_bolt11_settled(&bolt11.id).await?; + command::settle_invoice_out_of_band(&bolt11.id, &invoice.id).await?; // Re-fetch so the caller sees that it's been settled. Ok(query::get_bolt11(&bolt11.id).await?.unwrap_or(bolt11)) diff --git a/backend/src/command.rs b/backend/src/command.rs index db4e16d..7939bf8 100644 --- a/backend/src/command.rs +++ b/backend/src/command.rs @@ -52,14 +52,6 @@ pub async fn set_tenant_nwc_error(pubkey: &str, error: &str) -> Result<()> { Ok(()) } -pub async fn clear_tenant_nwc_error(pubkey: &str) -> Result<()> { - sqlx::query("UPDATE tenant SET nwc_error = NULL WHERE pubkey = ?") - .bind(pubkey) - .execute(pool()) - .await?; - Ok(()) -} - pub async fn set_tenant_stripe_error(pubkey: &str, error: &str) -> Result<()> { sqlx::query("UPDATE tenant SET stripe_error = ? WHERE pubkey = ?") .bind(error) @@ -69,22 +61,59 @@ pub async fn set_tenant_stripe_error(pubkey: &str, error: &str) -> Result<()> { Ok(()) } -pub async fn clear_tenant_stripe_error(pubkey: &str) -> Result<()> { - sqlx::query("UPDATE tenant SET stripe_error = NULL WHERE pubkey = ?") - .bind(pubkey) - .execute(pool()) - .await?; +/// Atomically churn a tenant whose grace period has elapsed: set the churn +/// marker, mark every active relay delinquent, and void the unpaid invoices. +pub async fn churn_tenant(tenant_pubkey: &str, now: i64, relays: &[Relay]) -> Result<()> { + let activities = with_tx(async |tx| { + set_tenant_churned_at_tx(tx, tenant_pubkey, Some(now)).await?; + + let mut activities = Vec::new(); + for relay in relays { + if relay.status == RELAY_STATUS_ACTIVE { + let activity = + set_relay_status_tx(tx, relay, RELAY_STATUS_DELINQUENT, "mark_relay_delinquent") + .await?; + activities.push(activity); + } + } + + void_open_invoices_tx(tx, tenant_pubkey).await?; + + Ok(activities) + }) + .await?; + + for activity in activities { + publish(activity); + } Ok(()) } -/// Set or clear the tenant's churn marker. Set when an invoice ages past the -/// grace period, cleared when billing is re-activated. -pub async fn set_tenant_churned_at(pubkey: &str, churned_at: Option) -> Result<()> { - sqlx::query("UPDATE tenant SET churned_at = ? WHERE pubkey = ?") - .bind(churned_at) - .bind(pubkey) - .execute(pool()) - .await?; +/// Atomically re-activate a churned tenant: clear the churn marker, restore every +/// delinquent relay to active, and void any still-open invoices. +pub async fn reactivate_tenant(tenant_pubkey: &str, relays: &[Relay]) -> Result<()> { + let activities = with_tx(async |tx| { + set_tenant_churned_at_tx(tx, tenant_pubkey, None).await?; + + let mut activities = Vec::new(); + for relay in relays { + if relay.status == RELAY_STATUS_DELINQUENT { + let activity = + set_relay_status_tx(tx, relay, RELAY_STATUS_ACTIVE, "unmark_relay_delinquent") + .await?; + activities.push(activity); + } + } + + void_open_invoices_tx(tx, tenant_pubkey).await?; + + Ok(activities) + }) + .await?; + + for activity in activities { + publish(activity); + } Ok(()) } @@ -177,31 +206,9 @@ pub async fn deactivate_relay(relay: &Relay) -> Result<()> { set_relay_status(relay, RELAY_STATUS_INACTIVE, "deactivate_relay").await } -pub async fn mark_relay_delinquent(relay: &Relay) -> Result<()> { - set_relay_status(relay, RELAY_STATUS_DELINQUENT, "mark_relay_delinquent").await -} - -/// Restore a delinquent relay to active when billing is re-activated. Unlike -/// `activate_relay` this records a non-billable activity, so resuming service -/// after churn doesn't levy a fresh prorated charge. -pub async fn unmark_relay_delinquent(relay: &Relay) -> Result<()> { - set_relay_status(relay, RELAY_STATUS_ACTIVE, "unmark_relay_delinquent").await -} - async fn set_relay_status(relay: &Relay, status: &str, activity_type: &str) -> Result<()> { - let activity = with_tx(async |tx| { - sqlx::query("UPDATE relay SET status = ?, synced = 0 WHERE id = ?") - .bind(status) - .bind(&relay.id) - .execute(&mut **tx) - .await?; - let snapshot = Snapshot::Relay { - plan: relay.plan_id.clone(), - status: status.to_string(), - }; - insert_activity_tx(tx, activity_type, &relay.id, snapshot).await - }) - .await?; + let activity = + with_tx(async |tx| set_relay_status_tx(tx, relay, status, activity_type).await).await?; publish(activity); Ok(()) } @@ -332,7 +339,7 @@ pub async fn create_invoice(tenant: &Tenant, period: &BillingPeriod) -> Result Result Result<()> { - let paid_at = chrono::Utc::now().timestamp(); +// --- Payment settlement --- - sqlx::query("UPDATE invoice SET method = ?, paid_at = ? WHERE id = ?") - .bind(method) - .bind(paid_at) - .bind(invoice_id) - .execute(pool()) - .await?; - Ok(()) +/// Atomically record an NWC-settled invoice: clear the tenant's stored NWC error, +/// mark the bolt11 settled, and mark the invoice paid. +pub async fn settle_invoice_via_nwc( + tenant_pubkey: &str, + bolt11_id: &str, + invoice_id: &str, +) -> Result<()> { + with_tx(async |tx| { + clear_tenant_nwc_error_tx(tx, tenant_pubkey).await?; + mark_bolt11_settled_tx(tx, bolt11_id).await?; + mark_invoice_paid_tx(tx, invoice_id, "nwc").await + }) + .await } -/// Void all of a tenant's open invoices, forgiving the balance — used when a -/// tenant churns or re-activates, so old debt never has to be collected. -pub async fn void_open_invoices(tenant_pubkey: &str) -> Result<()> { - let voided_at = chrono::Utc::now().timestamp(); +/// Atomically record a Lightning settlement that happened out of band. +pub async fn settle_invoice_out_of_band(bolt11_id: &str, invoice_id: &str) -> Result<()> { + with_tx(async |tx| { + mark_bolt11_settled_tx(tx, bolt11_id).await?; + mark_invoice_paid_tx(tx, invoice_id, "oob").await + }) + .await +} - sqlx::query( - "UPDATE invoice SET voided_at = ? - WHERE tenant_pubkey = ? AND paid_at IS NULL AND voided_at IS NULL", - ) - .bind(voided_at) - .bind(tenant_pubkey) - .execute(pool()) - .await?; - Ok(()) +/// Atomically record a Stripe-settled invoice: persist the PaymentIntent, clear +/// the tenant's stored Stripe error, and mark the invoice paid. +pub async fn settle_invoice_via_stripe( + tenant_pubkey: &str, + intent_id: &str, + invoice_id: &str, +) -> Result<()> { + with_tx(async |tx| { + insert_intent_tx(tx, intent_id, invoice_id).await?; + clear_tenant_stripe_error_tx(tx, tenant_pubkey).await?; + mark_invoice_paid_tx(tx, invoice_id, "stripe").await + }) + .await } // --- Bolt11 records --- @@ -401,37 +421,6 @@ pub async fn insert_bolt11( .await?) } -pub async fn mark_bolt11_settled(bolt11_id: &str) -> Result<()> { - let settled_at = chrono::Utc::now().timestamp(); - - sqlx::query("UPDATE bolt11 SET settled_at = ? WHERE id = ?") - .bind(settled_at) - .bind(bolt11_id) - .execute(pool()) - .await?; - Ok(()) -} - -// --- Intents --- - -/// Record the Stripe PaymentIntent that paid an invoice. Keyed by the Stripe -/// PaymentIntent id, so it's idempotent: a retried (idempotent) charge returns -/// the same id and the re-insert is a no-op rather than a primary-key conflict. -pub async fn insert_intent(intent_id: &str, invoice_id: &str) -> Result<()> { - let created_at = chrono::Utc::now().timestamp(); - - sqlx::query( - "INSERT INTO intent (id, invoice_id, created_at) - VALUES (?, ?, ?) ON CONFLICT(id) DO NOTHING", - ) - .bind(intent_id) - .bind(invoice_id) - .bind(created_at) - .execute(pool()) - .await?; - Ok(()) -} - // --- Internal utils that take an explicit transaction --- async fn insert_activity_tx( @@ -484,16 +473,18 @@ async fn insert_invoice_tx( tx: &mut Transaction<'_, Sqlite>, tenant: &Tenant, period: &BillingPeriod, + amount: i64, ) -> Result { let now = chrono::Utc::now().timestamp(); let invoice_id = uuid::Uuid::new_v4().to_string(); Ok(sqlx::query_as::<_, Invoice>( - "INSERT INTO invoice (id, tenant_pubkey, period_start, period_end, created_at) - VALUES (?, ?, ?, ?, ?) RETURNING *", + "INSERT INTO invoice (id, tenant_pubkey, amount, period_start, period_end, created_at) + VALUES (?, ?, ?, ?, ?, ?) RETURNING *", ) .bind(invoice_id) .bind(&tenant.pubkey) + .bind(amount) .bind(&period.start) .bind(period.end) .bind(now) @@ -536,3 +527,119 @@ async fn mark_activity_billed_tx( .await?; Ok(result.rows_affected() > 0) } + +/// Set a relay's status (and flag it for re-sync), recording the matching +/// activity. Returns the activity so the caller can `publish` it after the +/// enclosing transaction commits. +async fn set_relay_status_tx( + tx: &mut Transaction<'_, Sqlite>, + relay: &Relay, + status: &str, + activity_type: &str, +) -> Result { + sqlx::query("UPDATE relay SET status = ?, synced = 0 WHERE id = ?") + .bind(status) + .bind(&relay.id) + .execute(&mut **tx) + .await?; + let snapshot = Snapshot::Relay { + plan: relay.plan_id.clone(), + status: status.to_string(), + }; + insert_activity_tx(tx, activity_type, &relay.id, snapshot).await +} + +async fn mark_bolt11_settled_tx(tx: &mut Transaction<'_, Sqlite>, bolt11_id: &str) -> Result<()> { + let settled_at = chrono::Utc::now().timestamp(); + + sqlx::query("UPDATE bolt11 SET settled_at = ? WHERE id = ?") + .bind(settled_at) + .bind(bolt11_id) + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn mark_invoice_paid_tx( + tx: &mut Transaction<'_, Sqlite>, + invoice_id: &str, + method: &str, +) -> Result<()> { + let paid_at = chrono::Utc::now().timestamp(); + + sqlx::query("UPDATE invoice SET method = ?, paid_at = ? WHERE id = ?") + .bind(method) + .bind(paid_at) + .bind(invoice_id) + .execute(&mut **tx) + .await?; + Ok(()) +} + +/// Void all of a tenant's open invoices, forgiving the balance — used when a +/// tenant churns or re-activates, so old debt never has to be collected. +async fn void_open_invoices_tx(tx: &mut Transaction<'_, Sqlite>, tenant_pubkey: &str) -> Result<()> { + let voided_at = chrono::Utc::now().timestamp(); + + sqlx::query( + "UPDATE invoice SET voided_at = ? + WHERE tenant_pubkey = ? AND paid_at IS NULL AND voided_at IS NULL", + ) + .bind(voided_at) + .bind(tenant_pubkey) + .execute(&mut **tx) + .await?; + Ok(()) +} + +/// Set or clear the tenant's churn marker. Set when an invoice ages past the +/// grace period, cleared when billing is re-activated. +async fn set_tenant_churned_at_tx( + tx: &mut Transaction<'_, Sqlite>, + pubkey: &str, + churned_at: Option, +) -> Result<()> { + sqlx::query("UPDATE tenant SET churned_at = ? WHERE pubkey = ?") + .bind(churned_at) + .bind(pubkey) + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn clear_tenant_nwc_error_tx(tx: &mut Transaction<'_, Sqlite>, pubkey: &str) -> Result<()> { + sqlx::query("UPDATE tenant SET nwc_error = NULL WHERE pubkey = ?") + .bind(pubkey) + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn clear_tenant_stripe_error_tx(tx: &mut Transaction<'_, Sqlite>, pubkey: &str) -> Result<()> { + sqlx::query("UPDATE tenant SET stripe_error = NULL WHERE pubkey = ?") + .bind(pubkey) + .execute(&mut **tx) + .await?; + Ok(()) +} + +/// Record the Stripe PaymentIntent that paid an invoice. Keyed by the Stripe +/// PaymentIntent id, so it's idempotent. +async fn insert_intent_tx( + tx: &mut Transaction<'_, Sqlite>, + intent_id: &str, + invoice_id: &str, +) -> Result<()> { + let created_at = chrono::Utc::now().timestamp(); + + sqlx::query( + "INSERT INTO intent (id, invoice_id, created_at) + VALUES (?, ?, ?) ON CONFLICT(id) DO NOTHING", + ) + .bind(intent_id) + .bind(invoice_id) + .bind(created_at) + .execute(&mut **tx) + .await?; + Ok(()) +} diff --git a/backend/src/models.rs b/backend/src/models.rs index fcd0b8b..409047c 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -124,6 +124,9 @@ impl Default for Relay { pub struct Invoice { pub id: String, pub tenant_pubkey: String, + /// The total owed, fixed when the invoice is cut from its outstanding line + /// items, so collection never has to re-sum them. + pub amount: i64, pub period_start: i64, pub period_end: i64, pub created_at: i64, @@ -131,13 +134,6 @@ pub struct Invoice { pub voided_at: Option, } -impl Invoice { - /// An invoice is open — still collectible — until it's either paid or voided. - pub fn is_open(&self) -> bool { - self.paid_at.is_none() && self.voided_at.is_none() - } -} - #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct InvoiceItem { pub id: String, @@ -163,12 +159,3 @@ pub struct Bolt11 { pub expires_at: i64, pub settled_at: Option, } - -/// A Stripe PaymentIntent that paid an invoice, mirrored for the audit trail and -/// read back to show the user how an invoice was settled. -#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] -pub struct Intent { - pub id: String, - pub invoice_id: String, - pub created_at: i64, -} diff --git a/backend/src/query.rs b/backend/src/query.rs index cfc3a47..c574986 100644 --- a/backend/src/query.rs +++ b/backend/src/query.rs @@ -1,6 +1,6 @@ use anyhow::{Result, anyhow}; -use crate::models::{Activity, Bolt11, Intent, Invoice, InvoiceItem, Plan, Relay, Tenant}; +use crate::models::{Activity, Bolt11, Invoice, Plan, Relay, Tenant}; use crate::db::pool; fn select_tenant(tail: &str) -> String { @@ -156,29 +156,6 @@ pub async fn list_open_invoices(tenant_pubkey: &str) -> Result> { .await?) } -pub async fn get_invoice_items_for_invoice(invoice_id: &str) -> Result> { - Ok( - sqlx::query_as::<_, InvoiceItem>("SELECT * FROM invoice_item WHERE invoice_id = ?") - .bind(invoice_id) - .fetch_all(pool()) - .await?, - ) -} - -// --- Intents --- - -/// The Stripe PaymentIntents recorded against an invoice, newest first. -pub async fn list_intents_for_invoice(invoice_id: &str) -> Result> { - Ok( - sqlx::query_as::<_, Intent>( - "SELECT * FROM intent WHERE invoice_id = ? ORDER BY created_at DESC", - ) - .bind(invoice_id) - .fetch_all(pool()) - .await?, - ) -} - // --- Bolt11 --- pub async fn get_bolt11(bolt11_id: &str) -> Result> { diff --git a/backend/src/routes/invoices.rs b/backend/src/routes/invoices.rs index 0c4329a..dd7bbe1 100644 --- a/backend/src/routes/invoices.rs +++ b/backend/src/routes/invoices.rs @@ -1,53 +1,11 @@ use std::sync::Arc; use axum::extract::{Path, State}; -use serde::Serialize; use crate::api::{Api, AuthedPubkey}; -use crate::models::{Intent, Invoice}; use crate::query; use crate::web::{ApiResult, internal, not_found, ok}; -/// An invoice for the client, with its lifecycle flattened to a derived `status` -/// ("open" | "paid" | "void") alongside the underlying timestamps, plus the -/// Stripe PaymentIntents that settled it (empty unless requested). -#[derive(Serialize)] -pub struct InvoiceResponse { - pub id: String, - pub tenant_pubkey: String, - pub status: String, - pub period_start: i64, - pub period_end: i64, - pub created_at: i64, - pub paid_at: Option, - pub voided_at: Option, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub intents: Vec, -} - -impl From for InvoiceResponse { - fn from(i: Invoice) -> Self { - let status = if i.is_open() { - "open" - } else if i.paid_at.is_some() { - "paid" - } else { - "void" - }; - InvoiceResponse { - status: status.to_string(), - id: i.id, - tenant_pubkey: i.tenant_pubkey, - period_start: i.period_start, - period_end: i.period_end, - created_at: i.created_at, - paid_at: i.paid_at, - voided_at: i.voided_at, - intents: Vec::new(), - } - } -} - /// The tenant's most recent invoice, after first materializing any outstanding /// line items into a fresh one — so the frontend can collect payment right after /// a change (e.g. creating a relay). Payment isn't attempted here; the caller @@ -65,7 +23,7 @@ pub async fn get_tenant_latest_invoice( let invoice = query::get_latest_invoice(&pubkey).await.map_err(internal)?; - ok(invoice.map(InvoiceResponse::from)) + ok(invoice) } pub async fn get_invoice( @@ -80,10 +38,7 @@ pub async fn get_invoice( api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?; - let mut response = InvoiceResponse::from(invoice); - response.intents = query::list_intents_for_invoice(&id).await.map_err(internal)?; - - ok(response) + ok(invoice) } /// Return a payable Lightning invoice (bolt11) for an invoice, minting one if @@ -102,7 +57,7 @@ pub async fn get_invoice_bolt11( let bolt11 = api .billing - .ensure_and_reconcile_bolt11(&invoice_id) + .ensure_and_reconcile_bolt11(&invoice) .await .map_err(internal)?; diff --git a/backend/src/routes/tenants.rs b/backend/src/routes/tenants.rs index f45daac..e4e21f1 100644 --- a/backend/src/routes/tenants.rs +++ b/backend/src/routes/tenants.rs @@ -9,7 +9,6 @@ use serde::{Deserialize, Serialize}; use crate::api::{Api, AuthedPubkey}; use crate::models::Tenant; -use crate::routes::invoices::InvoiceResponse; use crate::web::{ApiResult, internal, map_unique_error, ok}; use crate::{command, env, query}; @@ -162,10 +161,7 @@ pub async fn list_tenant_invoices( .await .map_err(internal)?; - ok(invoices - .into_iter() - .map(InvoiceResponse::from) - .collect::>()) + ok(invoices) } #[derive(Deserialize)] diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 578d87b..7ad9167 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -2,7 +2,7 @@ import { A, useLocation } from "@solidjs/router" import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js" import Fuse from "fuse.js" import { primeProfiles, useProfilePicture, useTenant, useTenantRelays, type Relay } from "@/lib/hooks" -import { listTenantInvoices, type Invoice } from "@/lib/api" +import { invoiceStatus, listTenantInvoices, type Invoice } from "@/lib/api" import { account, eventStore, identity } from "@/lib/state" import serverIcon from "@/assets/server.svg" import Modal from "@/components/Modal" @@ -51,7 +51,7 @@ export default function AppShell(props: { children?: any }) { } try { const invoices = await listTenantInvoices(t.pubkey) - const openInvoice = invoices.find(inv => inv.status === "open") + const openInvoice = invoices.find(inv => invoiceStatus(inv) === "open") setPastDueInvoice(openInvoice) } catch { // ignore diff --git a/frontend/src/components/PaymentDialog.tsx b/frontend/src/components/PaymentDialog.tsx index b0ce575..76885ec 100644 --- a/frontend/src/components/PaymentDialog.tsx +++ b/frontend/src/components/PaymentDialog.tsx @@ -9,7 +9,7 @@ import { plans } from "@/lib/state" type PayStatus = "idle" | "loading" | "success" | "error" type Bolt11Status = "idle" | "loading" | "ready" | "error" -type PaymentInvoice = Pick & +type PaymentInvoice = Pick & Partial> type PaymentDialogProps = { @@ -68,7 +68,7 @@ export default function PaymentDialog(props: PaymentDialogProps) { setPayError("") try { const invoice = await getInvoice(props.invoice.id) - if (invoice.status === "paid") { + if (invoice.paid_at != null) { setPayStatus("success") } else { setPayStatus("error") @@ -90,7 +90,7 @@ export default function PaymentDialog(props: PaymentDialogProps) { props.onClose() } - const amountLabel = () => `$${(props.invoice.amount_due / 100).toFixed(2)}` + const amountLabel = () => `$${(props.invoice.amount / 100).toFixed(2)}` const periodLabel = () => { const { period_start, period_end } = props.invoice diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 19020bb..576fbf3 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -106,12 +106,22 @@ export type Tenant = { export type Invoice = { id: string - customer: string - status: string - amount_due: number - currency: string + tenant_pubkey: string + amount: number period_start: number period_end: number + created_at: number + paid_at: number | null + voided_at: number | null +} + +// The backend models an invoice's lifecycle as timestamps rather than a status +// field, so derive the display status from them: paid once paid_at is set, void +// once voided_at is set, otherwise still open. +export function invoiceStatus(invoice: Pick): "open" | "paid" | "void" { + if (invoice.paid_at != null) return "paid" + if (invoice.voided_at != null) return "void" + return "open" } export type Activity = { diff --git a/frontend/src/lib/hooks.ts b/frontend/src/lib/hooks.ts index 9cac0c5..c86c729 100644 --- a/frontend/src/lib/hooks.ts +++ b/frontend/src/lib/hooks.ts @@ -9,6 +9,7 @@ import { reactivateRelay, getRelay, getTenant, + invoiceStatus, listRelayActivity, listRelays, listTenantInvoices, @@ -141,7 +142,7 @@ export async function tenantNeedsPaymentSetup(): Promise { export async function getLatestOpenInvoice(): Promise { const invoices = await listTenantInvoices(account()!.pubkey) const open = invoices - .filter(inv => inv.status === "open" && inv.amount_due > 0) + .filter(inv => invoiceStatus(inv) === "open" && inv.amount > 0) .sort((a, b) => b.period_start - a.period_start) return open[0] ?? null } diff --git a/frontend/src/pages/Account.tsx b/frontend/src/pages/Account.tsx index e1c969b..299629c 100644 --- a/frontend/src/pages/Account.tsx +++ b/frontend/src/pages/Account.tsx @@ -5,7 +5,7 @@ import LoadingState from "@/components/LoadingState" import PaymentDialog from "@/components/PaymentDialog" import useMinLoading from "@/components/useMinLoading" import { updateActiveTenant, useTenant } from "@/lib/hooks" -import { createPortalSession, getInvoice, listTenantInvoices, type Invoice } from "@/lib/api" +import { createPortalSession, getInvoice, invoiceStatus, listTenantInvoices, type Invoice } from "@/lib/api" import { account } from "@/lib/state" export default function Account() { @@ -75,11 +75,9 @@ export default function Account() { } const invoiceStatusStyles: Record = { - draft: "bg-gray-100 text-gray-500 border-gray-200", open: "bg-yellow-50 text-yellow-700 border-yellow-200", paid: "bg-green-50 text-green-700 border-green-200", void: "bg-gray-100 text-gray-500 border-gray-200", - uncollectible: "bg-red-50 text-red-700 border-red-200", } return ( @@ -168,8 +166,9 @@ export default function Account() {
    {(invoice) => { - const isOpen = () => invoice.status === "open" - const statusStyle = () => invoiceStatusStyles[invoice.status] ?? "bg-gray-100 text-gray-500 border-gray-200" + const status = () => invoiceStatus(invoice) + const isOpen = () => status() === "open" + const statusStyle = () => invoiceStatusStyles[status()] ?? "bg-gray-100 text-gray-500 border-gray-200" const periodLabel = () => { const start = new Date(invoice.period_start * 1000) const end = new Date(invoice.period_end * 1000) @@ -185,7 +184,7 @@ export default function Account() {
    - ${(invoice.amount_due / 100).toFixed(2)} + ${(invoice.amount / 100).toFixed(2)}

    {periodLabel()}

    @@ -196,7 +195,7 @@ export default function Account() { Pay now
    - {invoice.status} + {status()}