Add checkout sessions for paying an invoice
This commit is contained in:
+172
-95
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user