Add checkout sessions for paying an invoice

This commit is contained in:
Jon Staab
2026-06-03 10:02:43 -07:00
parent 8c44d8cc0f
commit b702733559
13 changed files with 541 additions and 133 deletions
+172 -95
View File
@@ -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<O
// --- Payment settlement ---
/// 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 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<Option<Checkout>> {
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<bool> {
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<i64>,
) -> 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<Activity> {
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<bool> {
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<Activity> {
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<i64>,
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,