Some lightning invoice refactoring
Docker / build-and-push-image (backend, backend, coracle/caravel-backend) (push) Failing after 0s
Docker / build-and-push-image (frontend, frontend, coracle/caravel-frontend) (push) Failing after 1s

This commit is contained in:
Jon Staab
2026-05-21 17:19:35 -07:00
parent a998c9b833
commit e7c0e6fdbe
4 changed files with 75 additions and 157 deletions
+35 -111
View File
@@ -4,24 +4,18 @@ use std::collections::BTreeMap;
use crate::bitcoin; use crate::bitcoin;
use crate::command::Command; use crate::command::Command;
use crate::env::Env; use crate::env::Env;
use crate::models::{Activity, Tenant, RELAY_STATUS_ACTIVE}; use crate::models::{Activity, LightningInvoice, Tenant, RELAY_STATUS_ACTIVE};
use crate::query::Query; use crate::query::Query;
use crate::stripe::{Stripe, StripeInvoice, StripeSubscription}; use crate::stripe::{Stripe, StripeInvoice, StripeSubscription};
use crate::wallet::Wallet; use crate::wallet::Wallet;
const LIGHTNING_INVOICE_DESCRIPTION: &str = "Relay subscription payment";
/// How long a freshly minted bolt11 stays valid. Once it lapses, an unpaid
/// invoice's bolt11 is regenerated on next access, so the tenant is never shown
/// a dead invoice and the sat amount stays pegged to the current BTC price.
const BOLT11_EXPIRY_SECS: i64 = 3600;
#[derive(Clone)] #[derive(Clone)]
pub struct Billing { pub struct Billing {
stripe: Stripe, stripe: Stripe,
wallet: Wallet, wallet: Wallet,
query: Query, query: Query,
command: Command, command: Command,
env: Env,
} }
impl Billing { impl Billing {
@@ -31,9 +25,12 @@ impl Billing {
wallet: Wallet::from_url(&env.robot_wallet).expect("invalid ROBOT_WALLET"), wallet: Wallet::from_url(&env.robot_wallet).expect("invalid ROBOT_WALLET"),
query, query,
command, command,
env: env.clone(),
} }
} }
// --- lifecycle methods ---
pub async fn start(self) { pub async fn start(self) {
let mut rx = self.command.notify.subscribe(); let mut rx = self.command.notify.subscribe();
@@ -131,7 +128,7 @@ impl Billing {
self.ensure_subscription_items(subscription, quantity_by_price_id).await self.ensure_subscription_items(subscription, quantity_by_price_id).await
} }
// --Stripe helpers-- // --- Stripe helpers ---
/// Gets a map of stripe_price_id -> quantity based on the tenant's current relays /// Gets a map of stripe_price_id -> quantity based on the tenant's current relays
async fn get_quantity_by_price_id(&self, tenant: &Tenant) -> Result<BTreeMap<String, i64>> { async fn get_quantity_by_price_id(&self, tenant: &Tenant) -> Result<BTreeMap<String, i64>> {
@@ -228,131 +225,58 @@ impl Billing {
Ok(()) Ok(())
} }
// --- Public API helpers --- // --- lightning helpers ---
pub async fn create_bolt11(&self, amount_due_minor: i64, currency: &str) -> Result<String> { /// return or generate a lightning invoice for an open stripe invoice
let amount_msats = bitcoin::fiat_to_msats(amount_due_minor, currency).await?;
self.wallet
.make_invoice(
amount_msats,
LIGHTNING_INVOICE_DESCRIPTION,
BOLT11_EXPIRY_SECS as u64,
)
.await
}
/// Return the current valid bolt11 for an open invoice, minting one if none
/// exists and regenerating it if the stored one has expired. There is
/// exactly one bolt11 per invoice: whoever pays it — the tenant's NWC wallet
/// or a human — settles the same Lightning invoice, so the bolt11 itself is
/// the double-charge guard.
pub async fn ensure_lightning_invoice( pub async fn ensure_lightning_invoice(
&self, &self,
stripe_invoice_id: &str, stripe_invoice_id: &str,
tenant_pubkey: &str, tenant_pubkey: &str,
amount_due_minor: i64, amount_due: i64,
currency: &str, currency: &str,
) -> Result<String> { ) -> Result<LightningInvoice> {
let now = chrono::Utc::now().timestamp(); let now = chrono::Utc::now().timestamp();
if let Some(existing) = self.query.get_lightning_invoice(stripe_invoice_id).await? { if let Some(existing) = self.query.get_lightning_invoice(stripe_invoice_id).await?
// Keep a still-valid invoice, or any bolt11 we've already settled. && (existing.status != "pending" || now < existing.expires_at)
if existing.status != "pending" || now < existing.expires_at { {
return Ok(existing.bolt11); return Ok(existing);
}
// The stored invoice expired unpaid, so mint a fresh one. The old
// invoice can no longer be paid, so no settlement can be missed.
let bolt11 = self.create_bolt11(amount_due_minor, currency).await?;
self.command
.regenerate_lightning_invoice(stripe_invoice_id, &bolt11, now + BOLT11_EXPIRY_SECS)
.await?;
return Ok(bolt11);
} }
let bolt11 = self.create_bolt11(amount_due_minor, currency).await?; let expiry: i64 = 3600;
if self let info = "Relay subscription payment";
let msats = bitcoin::fiat_to_msats(amount_due, currency).await?;
let bolt11 = self.wallet.make_invoice(msats, info, expiry as u64).await?;
let invoice = match self
.command .command
.insert_pending_lightning_invoice( .insert_lightning_invoice(stripe_invoice_id, tenant_pubkey, &bolt11, now + expiry)
stripe_invoice_id,
tenant_pubkey,
&bolt11,
now + BOLT11_EXPIRY_SECS,
)
.await? .await?
{ {
Ok(bolt11) Some(invoice) => invoice,
} else { None => self
// Lost the insert race; use whatever the winner stored.
Ok(self
.query .query
.get_lightning_invoice(stripe_invoice_id) .get_lightning_invoice(stripe_invoice_id)
.await? .await?
.ok_or_else(|| { .ok_or_else(|| anyhow!("lightning_invoice {stripe_invoice_id} missing after upsert"))?,
anyhow!("lightning_invoice row missing after insert race for invoice {stripe_invoice_id}") };
})?
.bolt11) Ok(invoice)
}
} }
pub async fn pay_outstanding_card_invoices( /// Attempt to pay and settle an invoice via nwc
&self, pub async fn nwc_pay_invoice(&self, tenant: &Tenant, invoice: &LightningInvoice) -> Result<()> {
tenant: &crate::models::Tenant, let nwc_url = self.env.decrypt(&tenant.nwc_url)?;
) -> Result<()> { let tenant_wallet = Wallet::from_url(&nwc_url)?;
if !self
.stripe
.has_payment_method(&tenant.stripe_customer_id)
.await?
{
return Ok(());
}
let invoices = self match tenant_wallet.pay_invoice(invoice.bolt11.clone()).await {
.stripe Ok(()) => self.settle_invoice(&invoice.stripe_invoice_id, &tenant.pubkey, "nwc").await,
.list_invoices(&tenant.stripe_customer_id)
.await?;
for invoice in &invoices {
if invoice.status != "open" || invoice.amount_due == 0 {
continue;
}
if let Err(error) = self.stripe.pay_invoice(&invoice.id).await {
tracing::error!(
error = %error,
stripe_invoice_id = %invoice.id,
"failed to retry card payment for outstanding invoice"
);
}
}
Ok(())
}
// --- Lightning / NWC orchestration ---
/// Push a payment for an invoice's persisted bolt11 from the tenant's NWC
/// wallet, then confirm settlement against the system wallet. On success the
/// Stripe invoice is marked paid out of band and `Ok(())` is returned; on
/// failure the returned error is the reason to surface to the tenant (the
/// caller falls through to other payment methods rather than propagating
/// it). Reusing the same bolt11 means a retry — or a concurrent manual
/// payment — can never double-charge.
pub async fn nwc_pay_invoice(
&self,
stripe_invoice_id: &str,
tenant_pubkey: &str,
bolt11: &str,
tenant_nwc_url: &str,
) -> Result<()> {
let tenant_wallet = Wallet::from_url(tenant_nwc_url)?;
match tenant_wallet.pay_invoice(bolt11.to_string()).await {
Ok(()) => self.settle_invoice(stripe_invoice_id, tenant_pubkey, "nwc").await,
Err(pay_error) => { Err(pay_error) => {
// The pay request errored, but the payment may have landed // The pay request errored, but the payment may have landed
// before the response was lost. Confirm against the system // before the response was lost. Confirm against the system
// wallet before reporting failure. // wallet before reporting failure.
if self.wallet.is_settled(bolt11).await.unwrap_or(false) { if self.wallet.is_settled(&invoice.bolt11).await.unwrap_or(false) {
self.settle_invoice(stripe_invoice_id, tenant_pubkey, "nwc").await self.settle_invoice(&invoice.stripe_invoice_id, &tenant.pubkey, "nwc").await
} else { } else {
Err(pay_error) Err(pay_error)
} }
+18 -35
View File
@@ -3,7 +3,8 @@ use sqlx::{Sqlite, SqlitePool, Transaction};
use tokio::sync::broadcast; use tokio::sync::broadcast;
use crate::models::{ use crate::models::{
Activity, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Tenant, Activity, LightningInvoice, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT,
RELAY_STATUS_INACTIVE, Relay, Tenant,
}; };
#[derive(Clone)] #[derive(Clone)]
@@ -298,22 +299,29 @@ impl Command {
// Invoices // Invoices
/// Insert a freshly minted pending bolt11 for an invoice. Returns `false` if /// Upsert the pending bolt11 for an invoice, returning the resulting row. On
/// a row already exists (lost an insert race), in which case the caller /// conflict the stored bolt11/expiry are replaced — this is how an expired
/// should read and use the existing row's bolt11. /// invoice is regenerated — except once the invoice is paid, when the
pub async fn insert_pending_lightning_invoice( /// `status = 'pending'` guard makes the update a no-op and `None` is
/// returned so the caller can fall back to reading the settled row.
pub async fn insert_lightning_invoice(
&self, &self,
stripe_invoice_id: &str, stripe_invoice_id: &str,
tenant_pubkey: &str, tenant_pubkey: &str,
bolt11: &str, bolt11: &str,
expires_at: i64, expires_at: i64,
) -> Result<bool> { ) -> Result<Option<LightningInvoice>> {
let now = chrono::Utc::now().timestamp(); let now = chrono::Utc::now().timestamp();
let result = sqlx::query( let row = sqlx::query_as::<_, LightningInvoice>(
"INSERT INTO lightning_invoice "INSERT INTO lightning_invoice
(stripe_invoice_id, tenant_pubkey, bolt11, status, expires_at, created_at, updated_at) (stripe_invoice_id, tenant_pubkey, bolt11, status, expires_at, created_at, updated_at)
VALUES (?, ?, ?, 'pending', ?, ?, ?) VALUES (?, ?, ?, 'pending', ?, ?, ?)
ON CONFLICT(stripe_invoice_id) DO NOTHING", ON CONFLICT(stripe_invoice_id) DO UPDATE SET
bolt11 = excluded.bolt11,
expires_at = excluded.expires_at,
updated_at = excluded.updated_at
WHERE status = 'pending'
RETURNING *",
) )
.bind(stripe_invoice_id) .bind(stripe_invoice_id)
.bind(tenant_pubkey) .bind(tenant_pubkey)
@@ -321,35 +329,10 @@ impl Command {
.bind(expires_at) .bind(expires_at)
.bind(now) .bind(now)
.bind(now) .bind(now)
.execute(&self.pool) .fetch_optional(&self.pool)
.await?; .await?;
Ok(result.rows_affected() > 0) Ok(row)
}
/// Replace the stored bolt11 for a still-pending invoice whose previous
/// invoice expired. No-op once the invoice is paid, so this can never
/// overwrite a settled invoice.
pub async fn regenerate_lightning_invoice(
&self,
stripe_invoice_id: &str,
bolt11: &str,
expires_at: i64,
) -> Result<()> {
let now = chrono::Utc::now().timestamp();
sqlx::query(
"UPDATE lightning_invoice
SET bolt11 = ?, expires_at = ?, updated_at = ?
WHERE stripe_invoice_id = ? AND status = 'pending'",
)
.bind(bolt11)
.bind(expires_at)
.bind(now)
.bind(stripe_invoice_id)
.execute(&self.pool)
.await?;
Ok(())
} }
/// Mark a pending invoice paid, recording which method settled it. The /// Mark a pending invoice paid, recording which method settled it. The
+2 -2
View File
@@ -57,13 +57,13 @@ pub async fn get_lightning_invoice(
return Err(bad_request("invoice-not-open", "invoice is not open")); return Err(bad_request("invoice-not-open", "invoice is not open"));
} }
let bolt11 = api let invoice = api
.billing .billing
.ensure_lightning_invoice(&invoice.id, &tenant.pubkey, invoice.amount_due, &invoice.currency) .ensure_lightning_invoice(&invoice.id, &tenant.pubkey, invoice.amount_due, &invoice.currency)
.await .await
.map_err(internal)?; .map_err(internal)?;
ok(serde_json::json!({ "bolt11": bolt11 })) ok(serde_json::json!(invoice))
} }
/// Fetch a Stripe invoice and the tenant that owns it, enforcing that the /// Fetch a Stripe invoice and the tenant that owns it, enforcing that the
+20 -9
View File
@@ -127,8 +127,7 @@ async fn handle_invoice_created(
return Ok(()); return Ok(());
}; };
// Mint (or reuse) the single bolt11 that both NWC and manual payment settle. let invoice = api
let bolt11 = api
.billing .billing
.ensure_lightning_invoice(stripe_invoice_id, &tenant.pubkey, amount_due, currency) .ensure_lightning_invoice(stripe_invoice_id, &tenant.pubkey, amount_due, currency)
.await?; .await?;
@@ -137,12 +136,7 @@ async fn handle_invoice_created(
// 1. NWC auto-pay: if the tenant has a nwc_url // 1. NWC auto-pay: if the tenant has a nwc_url
if !tenant.nwc_url.is_empty() { if !tenant.nwc_url.is_empty() {
let plain_nwc_url = api.env.decrypt(&tenant.nwc_url)?; match api.billing.nwc_pay_invoice(&tenant, &invoice).await {
match api
.billing
.nwc_pay_invoice(stripe_invoice_id, &tenant.pubkey, &bolt11, &plain_nwc_url)
.await
{
Ok(()) => return Ok(()), Ok(()) => return Ok(()),
Err(e) => { Err(e) => {
let error_msg = format!("{e}"); let error_msg = format!("{e}");
@@ -309,7 +303,24 @@ async fn handle_payment_method_attached(api: &Api, stripe_customer_id: &str) ->
return Ok(()); return Ok(());
}; };
api.billing.pay_outstanding_card_invoices(&tenant).await?; let invoices = api
.stripe
.list_invoices(&tenant.stripe_customer_id)
.await?;
for invoice in &invoices {
if invoice.status != "open" || invoice.amount_due == 0 {
continue;
}
if let Err(error) = api.stripe.pay_invoice(&invoice.id).await {
tracing::error!(
error = %error,
stripe_invoice_id = %invoice.id,
"failed to retry card payment for outstanding invoice"
);
}
}
Ok(()) Ok(())
} }