diff --git a/AGENTS.md b/AGENTS.md index 18d3e39..81367f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,8 @@ When referring to a tenant's pubkey, always name it `tenant_pubkey`, not `tenant Pre-release: squash schema changes into `0001_init.sql` rather than adding new migration files. Once released, migrations become append-only. +Document indexes (what use cases they support), but not tables (those are documented in `models.rs`). + ## Markdown Do not hard-break markdown files at a certain number of characters. Allow readers to implement line wrapping naturally instead. diff --git a/backend/migrations/0001_init.sql b/backend/migrations/0001_init.sql index ee561ed..b84e129 100644 --- a/backend/migrations/0001_init.sql +++ b/backend/migrations/0001_init.sql @@ -91,6 +91,16 @@ CREATE TABLE IF NOT EXISTS intent ( FOREIGN KEY (invoice_id) REFERENCES invoice(id) ); +CREATE TABLE IF NOT EXISTS checkout ( + id TEXT PRIMARY KEY, + invoice_id TEXT NOT NULL, + url TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + settled_at INTEGER, + FOREIGN KEY (invoice_id) REFERENCES invoice(id) +); + CREATE INDEX IF NOT EXISTS idx_activity_tenant_created ON activity (tenant_pubkey, created_at); CREATE INDEX IF NOT EXISTS idx_activity_resource_created ON activity (resource_id, created_at); @@ -112,3 +122,5 @@ 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_checkout_invoice_created ON checkout (invoice_id, created_at); diff --git a/backend/src/api.rs b/backend/src/api.rs index e06c2a0..9f6e64f 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -33,7 +33,8 @@ use crate::query; use crate::robot::Robot; use crate::routes::identity::get_identity; use crate::routes::invoices::{ - ensure_invoice_bolt11, get_invoice, list_invoice_items, list_invoices, reconcile_invoice, + create_invoice_checkout, ensure_invoice_bolt11, get_invoice, list_invoice_items, list_invoices, + reconcile_invoice, }; use crate::routes::plans::{get_plan, list_plans}; use crate::routes::relays::{ @@ -93,6 +94,7 @@ impl Api { .route("/invoices", get(list_invoices)) .route("/invoices/:id", get(get_invoice)) .route("/invoices/:id/reconcile", post(reconcile_invoice)) + .route("/invoices/:id/checkout", post(ensure_invoice_checkout)) .route("/invoices/:id/bolt11", post(ensure_invoice_bolt11)) .route("/invoices/:id/items", get(list_invoice_items)) .with_state(api) diff --git a/backend/src/billing.rs b/backend/src/billing.rs index db9d4c1..e16821a 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -5,7 +5,7 @@ use crate::bitcoin; use crate::command; use crate::env; use crate::models::{ - Activity, Bolt11, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, Snapshot, Tenant, + Activity, Bolt11, Checkout, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, Snapshot, Tenant, }; use crate::query; use crate::robot::Robot; @@ -317,12 +317,15 @@ impl Billing { Ok(()) } - /// Collect an invoice via NWC, then a saved card, then (when `notify`) a - /// manual DM. A failing method's error is stored on the tenant (to warn them - /// in the UI) but never aborts the cascade or future retries; a method's - /// error is cleared when it next succeeds. Caller-initiated payments pass - /// `notify = false` to skip the dunning DM, since the failure is already - /// surfaced on screen. + /// Collect an invoice. We check the out-of-band rails first — a Lightning + /// invoice or Checkout session the tenant may have already paid — and only + /// then initiate a fresh charge (NWC, then a saved card), so a payment that's + /// already in flight is never duplicated. Falling all the way through sends a + /// manual-payment DM (when `notify`). A failing charge's error is stored on + /// the tenant (to warn them in the UI) but never aborts the cascade or future + /// retries; it's cleared when a method next succeeds. Caller-initiated + /// payments pass `notify = false` to skip the dunning DM, since the failure + /// is already surfaced on screen. pub async fn attempt_payment( &self, tenant: &Tenant, @@ -331,36 +334,53 @@ impl Billing { ) -> Result<()> { let mut error_message: Option = None; - // 1. NWC auto-pay: if the tenant has configured an nwc_url, try it first. - if !tenant.nwc_url.is_empty() { - match self.attempt_payment_using_nwc(tenant, invoice).await { - Ok(()) => return Ok(()), - Err(e) => error_message = Some(format!("{e}")), - } - } - - // 2. Out-of-band lightning: catches partially failed NWC or manual payment + // 1. Out-of-band lightning: settle a bolt11 paid out of band (e.g. a + // manual QR scan, or an NWC pay that completed but failed to record). + // Checked before any charge so we never bill on top of it. 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(()); + return self.cleanup_pending_payments(invoice).await; } - // 3. Payment method on file: charge the tenant's cached Stripe payment + // 2. Hosted Checkout: settle an invoice the tenant paid (and + // authenticated) on a Stripe Checkout session that has since completed. + // Also checked before any charge so we never bill on top of it. + if let Some(checkout) = query::get_checkout_for_invoice(&invoice.id).await? + && checkout.settled_at.is_none() + && self + .stripe + .is_checkout_paid(&checkout.id) + .await + .unwrap_or(false) + { + command::settle_invoice_via_checkout(&tenant.pubkey, &checkout.id, &invoice.id).await?; + return self.cleanup_pending_payments(invoice).await; + } + + // 3. NWC auto-pay: if the tenant has configured an nwc_url, charge it. + if !tenant.nwc_url.is_empty() { + match self.attempt_payment_using_nwc(tenant, invoice).await { + Ok(()) => return self.cleanup_pending_payments(invoice).await, + Err(e) => error_message = Some(format!("{e}")), + } + } + + // 4. Payment method on file: charge the tenant's cached Stripe payment // method, kept fresh by sync_stripe_payment_method before collection. if let Some(payment_method) = &tenant.stripe_payment_method_id { match self .attempt_payment_using_stripe(tenant, invoice, payment_method) .await { - Ok(()) => return Ok(()), + Ok(()) => return self.cleanup_pending_payments(invoice).await, Err(e) => error_message = error_message.or_else(|| Some(format!("{e}"))), } } - // 4. Manual payment: DM a link to the in-app payment page for this invoice. + // 5. Manual payment: DM a link to the in-app payment page for this invoice. if notify && let Err(e) = self .attempt_payment_using_dm(tenant, invoice, error_message) @@ -415,7 +435,7 @@ impl Billing { ) .await?; - command::settle_invoice_via_stripe(&tenant.pubkey, &intent_id, &invoice.id).await + command::settle_invoice_via_intent(&tenant.pubkey, &intent_id, &invoice.id).await } .await; @@ -470,6 +490,27 @@ impl Billing { // Record the send to avoid spammy notifications. command::mark_invoice_notified(invoice_id).await + + } + + /// Run after an invoice is settled to invalidate out-of-band payment methods + /// so the tenant can't pay twice. Only Stripe Checkout sessions can actually + /// be invalidated (by expiring them); Lightning/NWC has no such mechanism. + /// The session that just paid (if any) was marked settled in its settle + /// transaction, so it's excluded here and not needlessly expired. + async fn cleanup_pending_payments(&self, invoice: &Invoice) -> Result<()> { + for checkout in query::list_pending_checkouts_for_invoice(&invoice.id).await? { + if let Err(error) = self.stripe.expire_checkout_session(&checkout.id).await { + tracing::debug!( + invoice = %invoice.id, + checkout = %checkout.id, + error = %error, + "could not expire checkout session" + ); + } + } + + Ok(()) } // --- Bolt11 utils --- @@ -501,6 +542,57 @@ impl Billing { .ok_or_else(|| anyhow!("failed to insert bolt11")) } + // --- Checkout utils --- + + /// Idempotently produce a hosted Stripe Checkout session for an open invoice, + /// reusing an unsettled, unexpired one if present — the on-session card + /// counterpart to [`Self::ensure_bolt11_for_invoice`]. Checkout lets the + /// tenant clear a 3D Secure challenge the off-session card charge can't. On + /// success Stripe returns the tenant to their account page, where collection + /// is reconciled (here and on the dunning poll) once the session reads paid. + pub async fn ensure_checkout_for_invoice( + &self, + tenant: &Tenant, + invoice: &Invoice, + ) -> Result { + let now = chrono::Utc::now().timestamp(); + + // Never open a Checkout for an invoice that's already resolved. + if invoice.paid_at.is_some() || invoice.voided_at.is_some() { + return Err(anyhow!("invoice is not open")); + } + + // Reuse a still-valid pending session so repeated clicks land on one page. + if let Some(existing) = query::get_checkout_for_invoice(&invoice.id).await? + && now < existing.expires_at + { + if existing.settled_at.is_some() { + return Err(anyhow!("a checkout has already been settled for this invoice")); + } + + return Ok(existing); + } + + // Stripe returns the tenant to their account page; tag the invoice so the + // landing page reconciles it promptly instead of waiting for the poll. + let base = format!("{}/account", env::get().app_url); + let success_url = format!("{base}?paid_invoice={}", invoice.id); + + let (session_id, url, expires_at) = self + .stripe + .create_checkout_session( + &tenant.stripe_customer_id, + &invoice.id, + invoice.amount, + "usd", + &success_url, + &base, + ) + .await?; + + command::insert_checkout(&invoice.id, &session_id, &url, expires_at).await + } + // --- Stripe utils --- /// Refresh stripe-related state for a tenant, returning the synced payment diff --git a/backend/src/command.rs b/backend/src/command.rs index 5b5b58d..6e04cae 100644 --- a/backend/src/command.rs +++ b/backend/src/command.rs @@ -5,7 +5,7 @@ use sqlx::{Sqlite, Transaction}; use crate::billing::BillingPeriod; use crate::db::{pool, publish, with_tx}; use crate::models::{ - Activity, Bolt11, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, + Activity, Bolt11, Checkout, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Snapshot, Tenant, }; @@ -383,6 +383,16 @@ pub async fn create_invoice(tenant: &Tenant, period: &BillingPeriod) -> Result Result<()> { + with_tx(async |tx| { + mark_bolt11_settled_tx(tx, bolt11_id).await?; + mark_invoice_paid_tx(tx, invoice_id, "oob").await?; + Ok(()) + }) + .await +} + /// 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( @@ -399,26 +409,34 @@ pub async fn settle_invoice_via_nwc( .await } -/// 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?; - Ok(()) - }) - .await -} - /// 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( +pub async fn settle_invoice_via_intent( 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?; + insert_settled_intent_tx(tx, intent_id, invoice_id).await?; + mark_invoice_paid_tx(tx, invoice_id, "stripe").await?; + Ok(()) + }) + .await +} + +/// Atomically record an invoice paid via a hosted Checkout session: stamp the +/// checkout settled, clear the tenant's stored Stripe error, and mark the invoice +/// paid. The checkout was inserted unsettled by [`insert_checkout`]. `checkout_id` +/// is the Stripe Checkout Session id, which is the checkout's primary key. +pub async fn settle_invoice_via_checkout( + tenant_pubkey: &str, + checkout_id: &str, + invoice_id: &str, +) -> Result<()> { + with_tx(async |tx| { + clear_tenant_stripe_error_tx(tx, tenant_pubkey).await?; + mark_checkout_settled_tx(tx, checkout_id).await?; mark_invoice_paid_tx(tx, invoice_id, "stripe").await?; Ok(()) }) @@ -463,8 +481,37 @@ pub async fn insert_bolt11( .await?) } +// --- Checkout records --- + +/// Record a pending Stripe Checkout session for an invoice, returning the stored +/// [`Checkout`]. Mirrors [`insert_bolt11`]: created unsettled, then stamped by +/// [`settle_invoice_via_checkout`] once the session is paid. Keyed by the Stripe +/// Checkout Session id, the same way `intent` is keyed by its PaymentIntent id. +pub async fn insert_checkout( + invoice_id: &str, + session_id: &str, + url: &str, + expires_at: i64, +) -> Result> { + let created_at = chrono::Utc::now().timestamp(); + + Ok(sqlx::query_as::<_, Checkout>( + "INSERT INTO checkout (id, invoice_id, url, created_at, expires_at) + VALUES (?, ?, ?, ?, ?) RETURNING *", + ) + .bind(session_id) + .bind(invoice_id) + .bind(url) + .bind(created_at) + .bind(expires_at) + .fetch_optional(pool()) + .await?) +} + // --- Internal utils that take an explicit transaction --- +// --- Activities --- + async fn insert_activity_tx( tx: &mut Transaction<'_, Sqlite>, activity_type: &str, @@ -511,6 +558,84 @@ async fn insert_activity_tx( }) } +/// Claim an activity as billed. Returns `true` if this call set the marker, and +/// `false` if it was already set — e.g. a concurrent reconcile pass won the race — +/// so callers can skip work that would otherwise double-bill. +async fn mark_activity_billed_tx( + tx: &mut Transaction<'_, Sqlite>, + activity_id: &str, + billed_at: i64, +) -> Result { + let result = + sqlx::query("UPDATE activity SET billed_at = ? WHERE id = ? AND billed_at IS NULL") + .bind(billed_at) + .bind(activity_id) + .execute(&mut **tx) + .await?; + Ok(result.rows_affected() > 0) +} + +// --- Tenants --- + +/// 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(()) +} + +// --- Relays --- + +/// 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 +} + +// --- Invoices --- + async fn insert_invoice_tx( tx: &mut Transaction<'_, Sqlite>, tenant: &Tenant, @@ -557,59 +682,11 @@ async fn insert_invoice_item_tx( Ok(()) } -/// Claim an activity as billed. Returns `true` if this call set the marker, and -/// `false` if it was already set — e.g. a concurrent reconcile pass won the race — -/// so callers can skip work that would otherwise double-bill. -async fn mark_activity_billed_tx( - tx: &mut Transaction<'_, Sqlite>, - activity_id: &str, - billed_at: i64, -) -> Result { - let result = - sqlx::query("UPDATE activity SET billed_at = ? WHERE id = ? AND billed_at IS NULL") - .bind(billed_at) - .bind(activity_id) - .execute(&mut **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 -} - -/// Stamp a bolt11 as settled but don't overwrite an existing settled_at. -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 = ? AND settled_at IS NULL") - .bind(settled_at) - .bind(bolt11_id) - .execute(&mut **tx) - .await?; - Ok(()) -} - /// Mark an invoice paid, but only while it is still open — a late Lightning /// payment never flips a voided/forgiven invoice to paid, and a Stripe-paid -/// invoice never has its provenance overwritten by a later bolt11. +/// invoice never has its provenance overwritten by a later bolt11. When this +/// call is the one that settles it, close out the invoice's other outstanding +/// payment instruments so a late completion on another rail can't double-charge. async fn mark_invoice_paid_tx( tx: &mut Transaction<'_, Sqlite>, invoice_id: &str, @@ -625,8 +702,7 @@ async fn mark_invoice_paid_tx( .bind(paid_at) .bind(invoice_id) .execute(&mut **tx) - .await?; - Ok(()) + .await; } /// Void all of a tenant's open invoices, forgiving the balance — used when a @@ -648,43 +724,44 @@ async fn void_open_invoices_tx( 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( +// --- Bolt11 --- + +/// Stamp a bolt11 as settled but don't overwrite an existing settled_at. +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 = ? AND settled_at IS NULL") + .bind(settled_at) + .bind(bolt11_id) + .execute(&mut **tx) + .await?; + Ok(()) +} + +// --- Checkouts --- + +/// Stamp a checkout as settled but don't overwrite an existing settled_at, so a +/// re-reconcile of the same session is a no-op. Keyed by our row id. +async fn mark_checkout_settled_tx( tx: &mut Transaction<'_, Sqlite>, - pubkey: &str, - churned_at: Option, + checkout_id: &str, ) -> Result<()> { - sqlx::query("UPDATE tenant SET churned_at = ? WHERE pubkey = ?") - .bind(churned_at) - .bind(pubkey) + let settled_at = chrono::Utc::now().timestamp(); + + sqlx::query("UPDATE checkout SET settled_at = ? WHERE id = ? AND settled_at IS NULL") + .bind(settled_at) + .bind(checkout_id) .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(()) -} +// --- Intents --- -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( +/// Record the Stripe PaymentIntent that paid an invoice off-session. Keyed by +/// the Stripe PaymentIntent id, so it's an idempotent audit record of what paid +/// the invoice. +async fn insert_settled_intent_tx( tx: &mut Transaction<'_, Sqlite>, intent_id: &str, invoice_id: &str, diff --git a/backend/src/models.rs b/backend/src/models.rs index 377dd1e..450bbc1 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -166,3 +166,18 @@ pub struct Bolt11 { pub expires_at: i64, pub settled_at: Option, } + +/// A hosted Stripe Checkout session opened to pay an invoice on-session (so a 3D +/// Secure challenge can be cleared), shaped like [`Bolt11`]: created pending and +/// stamped `settled_at` once paid. `id` is the Stripe Checkout Session id +/// (`cs_…`), used to reconcile and expire it; `url` is the hosted page we +/// redirect the tenant to. +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Checkout { + pub id: String, + pub invoice_id: String, + pub url: String, + pub created_at: i64, + pub expires_at: i64, + pub settled_at: Option, +} diff --git a/backend/src/query.rs b/backend/src/query.rs index 6a4b4cb..868726d 100644 --- a/backend/src/query.rs +++ b/backend/src/query.rs @@ -1,7 +1,7 @@ use anyhow::{Result, anyhow}; use crate::db::pool; -use crate::models::{Activity, Bolt11, Invoice, InvoiceItem, Plan, Relay, Tenant}; +use crate::models::{Activity, Bolt11, Checkout, Invoice, InvoiceItem, Plan, Relay, Tenant}; fn select_tenant(tail: &str) -> String { format!("SELECT * FROM tenant {tail}") @@ -197,6 +197,31 @@ pub async fn get_bolt11_for_invoice(invoice_id: &str) -> Result> .await?) } +// --- Checkouts --- + +/// The most recent Checkout session for an invoice, regardless of `settled_at`, +/// so a session can still be expired on Stripe after we've locally marked it +/// settled. Mirrors [`get_bolt11_for_invoice`]; callers gate on `settled_at`. +pub async fn get_checkout_for_invoice(invoice_id: &str) -> Result> { + Ok(sqlx::query_as::<_, Checkout>( + "SELECT * FROM checkout WHERE invoice_id = ? ORDER BY created_at DESC LIMIT 1", + ) + .bind(invoice_id) + .fetch_optional(pool()) + .await?) +} + +/// Every still-pending (unsettled) Checkout session for an invoice — the ones to +/// expire on Stripe once the invoice has been paid another way. +pub async fn list_pending_checkouts_for_invoice(invoice_id: &str) -> Result> { + Ok(sqlx::query_as::<_, Checkout>( + "SELECT * FROM checkout WHERE invoice_id = ? AND settled_at IS NULL ORDER BY created_at DESC", + ) + .bind(invoice_id) + .fetch_all(pool()) + .await?) +} + // --- Activity --- /// Billable activity for a tenant not yet folded into an invoice. The diff --git a/backend/src/routes/invoices.rs b/backend/src/routes/invoices.rs index 803d073..ad36bae 100644 --- a/backend/src/routes/invoices.rs +++ b/backend/src/routes/invoices.rs @@ -4,7 +4,7 @@ use axum::extract::{Path, State}; use crate::api::{Api, AuthedPubkey}; use crate::query; -use crate::web::{ApiResult, internal, not_found, ok}; +use crate::web::{ApiResult, bad_request, internal, not_found, ok}; pub async fn list_invoices( State(api): State>, @@ -92,6 +92,33 @@ pub async fn ensure_invoice_bolt11( ok(bolt11) } +/// Open a hosted Stripe Checkout session to pay a single open invoice by card, +/// returning the URL to redirect the tenant to. Unlike the off-session card +/// charge, Checkout can satisfy a 3D Secure authentication challenge; the +/// resulting payment is reconciled by `reconcile_invoice` (or the dunning poll). +pub async fn ensure_invoice_checkout( + State(api): State>, + AuthedPubkey(auth): AuthedPubkey, + Path(id): Path, +) -> ApiResult { + let invoice = query::get_invoice(&id) + .await + .map_err(internal)? + .ok_or_else(|| not_found("invoice not found"))?; + + api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?; + + let tenant = api.get_tenant_or_404(&invoice.tenant_pubkey).await?; + + let checkout = api + .billing + .ensure_checkout_for_invoice(&tenant, &invoice) + .await + .map_err(internal)?; + + ok(serde_json::json!({ "url": checkout.url })) +} + /// The line items billed on an invoice pub async fn list_invoice_items( State(api): State>, diff --git a/backend/src/stripe.rs b/backend/src/stripe.rs index 97bad34..d892f09 100644 --- a/backend/src/stripe.rs +++ b/backend/src/stripe.rs @@ -128,6 +128,7 @@ impl Stripe { ("currency", currency), ("customer", customer_id), ("payment_method", payment_method_id), + ("metadata[invoice_id]", invoice_id), ("off_session", "true"), ("confirm", "true"), ]) @@ -148,6 +149,77 @@ impl Stripe { .ok_or_else(|| anyhow!("missing payment intent id")) } + // --- Checkout --- + + /// Open a hosted Stripe Checkout session that charges `amount` (in the + /// currency's minor units) for a single invoice on-session, so the customer + /// can satisfy a 3D Secure authentication that an off-session saved-card + /// charge can't. Returns the session id, its hosted URL, and its expiry. The + /// session and the PaymentIntent it creates both carry `invoice_id` in + /// metadata so the charge is traceable back to our ledger. + pub async fn create_checkout_session( + &self, + customer_id: &str, + invoice_id: &str, + amount: i64, + currency: &str, + success_url: &str, + cancel_url: &str, + ) -> Result<(String, String, i64)> { + let amount = amount.to_string(); + let body = self + .post("/checkout/sessions") + .form(&[ + ("mode", "payment"), + ("customer", customer_id), + ("success_url", success_url), + ("cancel_url", cancel_url), + ("line_items[0][quantity]", "1"), + ("line_items[0][price_data][currency]", currency), + ("line_items[0][price_data][unit_amount]", amount.as_str()), + ( + "line_items[0][price_data][product_data][name]", + "Relay subscription", + ), + ("payment_intent_data[metadata][invoice_id]", invoice_id), + ("metadata[invoice_id]", invoice_id), + ]) + .send_json() + .await?; + + let session_id = body["id"] + .as_str() + .ok_or_else(|| anyhow!("missing checkout session id"))?; + let url = body["url"] + .as_str() + .ok_or_else(|| anyhow!("missing checkout session url"))?; + let expires_at = body["expires_at"] + .as_i64() + .ok_or_else(|| anyhow!("missing checkout session expiry"))?; + Ok((session_id.to_string(), url.to_string(), expires_at)) + } + + /// Whether a Checkout session has been paid. Used to reconcile an invoice + /// once the customer returns from (or later completes) the hosted page. + pub async fn is_checkout_paid(&self, session_id: &str) -> Result { + let body = self + .get(&format!("/checkout/sessions/{session_id}")) + .send_json() + .await?; + Ok(body["payment_status"].as_str() == Some("paid")) + } + + /// Expire a Checkout session so it can no longer be completed. Used to close + /// out a still-open session once its invoice has been paid another way, + /// preventing a double charge. Errors if the session isn't open (already + /// completed or expired), which the caller treats as best-effort. + pub async fn expire_checkout_session(&self, session_id: &str) -> Result<()> { + self.post(&format!("/checkout/sessions/{session_id}/expire")) + .send_ok() + .await?; + Ok(()) + } + // --- Portal --- /// Open a Stripe billing-portal session for the customer, returning the URL diff --git a/frontend/src/components/PaymentDialog.tsx b/frontend/src/components/PaymentDialog.tsx index 76570a7..7067275 100644 --- a/frontend/src/components/PaymentDialog.tsx +++ b/frontend/src/components/PaymentDialog.tsx @@ -2,12 +2,11 @@ import { createEffect, createResource, createSignal, Show } from "solid-js" import QRCode from "qrcode" import Modal from "@/components/Modal" import PaymentSetup from "@/components/PaymentSetup" -import { CardSetupBody } from "@/components/PaymentSetupShell" import InvoiceItemsList from "@/components/payment/InvoiceItemsList" import LightningPayBody from "@/components/payment/LightningPayBody" import { setToastMessage } from "@/lib/state" import { copyToClipboard } from "@/lib/clipboard" -import { useCardPortal } from "@/lib/usePaymentSetup" +import { useInvoiceCheckout } from "@/lib/usePaymentSetup" import { ensureInvoiceBolt11, listInvoiceItems, reconcileInvoice, type Invoice } from "@/lib/api" import { autopayConfigured } from "@/lib/paymentMethod" import { billingTenant } from "@/lib/state" @@ -40,9 +39,11 @@ export default function PaymentDialog(props: PaymentDialogProps) { listInvoiceItems, ) - // Card payment is a redirect to the Stripe billing portal; once a card is on - // file we retry collection on this invoice automatically. - const card = useCardPortal() + // Paying by card opens a Stripe Checkout session scoped to this invoice (which + // can clear a 3D Secure challenge the off-session charge can't), then returns + // here where the payment is reconciled. Distinct from PaymentSetup, which + // manages the recurring card on file via the billing portal. + const checkout = useInvoiceCheckout(() => props.invoice.id) const hasAutopay = () => { const t = billingTenant() @@ -72,10 +73,10 @@ export default function PaymentDialog(props: PaymentDialogProps) { void loadBolt11() }) - // The card portal lives in a shared hook, so surface its failures here by - // mirroring its error signal into the toast. + // The checkout redirect lives in a shared hook, so surface its failures here + // by mirroring its error signal into the toast. createEffect(() => { - const err = card.error() + const err = checkout.error() if (err) setToastMessage(err) }) @@ -106,7 +107,7 @@ export default function PaymentDialog(props: PaymentDialogProps) { setBolt11("") setQrDataUrl("") setPayMethod("lightning") - card.reset() + checkout.reset() props.onClose() } @@ -186,9 +187,27 @@ export default function PaymentDialog(props: PaymentDialogProps) { /> - {/* Card: redirect to the Stripe billing portal */} + {/* Card: redirect to a Stripe Checkout session for this invoice */} - +
+
+ + + + +
+

+ Pay this invoice on Stripe's secure checkout. You'll be redirected and brought back here once it's done. +

+ +
} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3a08168..07b96dd 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -335,6 +335,15 @@ export function ensureInvoiceBolt11(invoiceId: string) { return callApi("POST", `/invoices/${invoiceId}/bolt11`) } +// Open a hosted Stripe Checkout session to pay a single invoice by card, +// reusing a valid pending one. Unlike the off-session charge, Checkout can +// satisfy a 3D Secure challenge. Returns the URL to redirect to; the payment is +// reconciled by reconcileInvoice once the tenant returns (or by the poll). The +// return URL is fixed to the account page server-side. +export function createInvoiceCheckout(invoiceId: string) { + return callApi("POST", `/invoices/${invoiceId}/checkout`) +} + // Reconcile and collect an open invoice: ensure a payable bolt11 exists, then // run the payment cascade (NWC, then an out-of-band Lightning settle, then a // saved card). Caller-initiated, so no dunning DM and no churn. Returns the diff --git a/frontend/src/lib/usePaymentSetup.ts b/frontend/src/lib/usePaymentSetup.ts index 2cacb61..dae8f54 100644 --- a/frontend/src/lib/usePaymentSetup.ts +++ b/frontend/src/lib/usePaymentSetup.ts @@ -1,6 +1,6 @@ import { createSignal } from "solid-js" import { updateActiveTenant } from "@/lib/hooks" -import { createPortalSession } from "@/lib/api" +import { createInvoiceCheckout, createPortalSession } from "@/lib/api" import { account } from "@/lib/state" // Lightning/NWC save state machine, shared by the combined and focused setup @@ -65,3 +65,33 @@ export function useCardPortal() { } export type CardPortal = ReturnType + +// Paying one specific invoice by card is a full-page redirect to a Stripe +// Checkout session scoped to that invoice (so a 3D Secure challenge can be +// completed) — distinct from the billing-portal redirect that manages the +// recurring card on file. Like the portal, there's no local "saved" state, only +// the in-flight redirect and any failure to open the session. +export function useInvoiceCheckout(invoiceId: () => string) { + const [redirecting, setRedirecting] = createSignal(false) + const [error, setError] = createSignal("") + + async function openCheckout() { + setRedirecting(true) + setError("") + try { + const { url } = await createInvoiceCheckout(invoiceId()) + window.location.href = url + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to open checkout") + setRedirecting(false) + } + } + + function reset() { + setError("") + } + + return { redirecting, error, openCheckout, reset } +} + +export type InvoiceCheckout = ReturnType diff --git a/frontend/src/pages/Account.tsx b/frontend/src/pages/Account.tsx index bc1ea33..b8395d9 100644 --- a/frontend/src/pages/Account.tsx +++ b/frontend/src/pages/Account.tsx @@ -7,9 +7,9 @@ import { useInvoicePdf } from "@/lib/useInvoicePdf" import PaymentSetupNWC from "@/components/PaymentSetupNWC" import PaymentSetupCard from "@/components/PaymentSetupCard" import { useBillingStatus, accountStatus, type AccountStatus } from "@/lib/billing" -import { invoiceStatus, listDraftInvoiceItems, type Invoice } from "@/lib/api" +import { invoiceStatus, listDraftInvoiceItems, reconcileInvoice, type Invoice } from "@/lib/api" import { cardState, methodLabel, nwcState, type PaymentMethodState } from "@/lib/paymentMethod" -import { account } from "@/lib/state" +import { account, setToastMessage } from "@/lib/state" import { formatPeriod } from "@/lib/format" import PaymentMethodRow from "@/components/account/PaymentMethodRow" import InvoiceListItem from "@/components/account/InvoiceListItem" @@ -51,6 +51,32 @@ export default function Account() { if (pubkey) void billing.autopay(pubkey) }) + // Returning from a per-invoice Stripe Checkout (the success_url carries + // ?paid_invoice=ID): reconcile that invoice so it flips to paid promptly — + // autopay above only collects when a recurring method is on file, and a + // one-off Checkout payment doesn't leave one — then strip the marker. + createEffect(() => { + const pubkey = account()?.pubkey + const paidInvoice = new URLSearchParams(window.location.search).get("paid_invoice") + if (!pubkey || !paidInvoice) return + void (async () => { + try { + const invoice = await reconcileInvoice(paidInvoice) + setToastMessage( + invoice.paid_at != null ? "Payment received. Thank you!" : "Your payment is still processing.", + ) + } catch (e) { + setToastMessage(e instanceof Error ? e.message : "Failed to confirm payment") + } finally { + const params = new URLSearchParams(window.location.search) + params.delete("paid_invoice") + const qs = params.toString() + window.history.replaceState({}, "", `${window.location.pathname}${qs ? `?${qs}` : ""}`) + billing.refetch() + } + })() + }) + // Coarse account-health summary for the badge. Same snapshot the inline prompt // consumes, so the two can never disagree. const status = createMemo(() =>