Compare commits

..

2 Commits

Author SHA1 Message Date
Jon Staab 43eaad1621 Differentiate checkout id/session id
Docker / build-and-push-image (push) Successful in 52m8s
2026-06-03 14:27:57 -07:00
Jon Staab 5e6d5ab7c4 Handle stripe's 50c minimum, avoid lost write 2026-06-03 14:14:54 -07:00
4 changed files with 131 additions and 53 deletions
+8
View File
@@ -88,13 +88,17 @@ CREATE TABLE IF NOT EXISTS bolt11 (
CREATE TABLE IF NOT EXISTS intent (
id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL,
payment_method_id TEXT NOT NULL,
payment_intent_id TEXT,
created_at INTEGER NOT NULL,
settled_at INTEGER,
FOREIGN KEY (invoice_id) REFERENCES invoice(id)
);
CREATE TABLE IF NOT EXISTS checkout (
id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL,
session_id TEXT NOT NULL,
url TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
@@ -125,3 +129,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_invoice_item_activity ON invoice_item (act
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);
-- At most one unsettled write-ahead intent per invoice: enforces the invariant
-- and is the ON CONFLICT target for the get-or-create in `ensure_pending_intent`.
CREATE UNIQUE INDEX IF NOT EXISTS idx_intent_unsettled ON intent (invoice_id) WHERE settled_at IS NULL;
+30 -22
View File
@@ -372,7 +372,7 @@ impl Billing {
&& checkout.settled_at.is_none()
&& self
.stripe
.is_checkout_paid(&checkout.id)
.is_checkout_paid(&checkout.session_id)
.await
.unwrap_or(false)
{
@@ -450,28 +450,36 @@ impl Billing {
invoice: &Invoice,
payment_method_id: &str,
) -> Result<()> {
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?;
let intent = command::ensure_pending_intent(&invoice.id, payment_method_id).await?;
command::settle_invoice_via_intent(&tenant.pubkey, &intent_id, &invoice.id).await
}
.await;
let payment_intent_id = match self
.stripe
.create_payment_intent(
&tenant.stripe_customer_id,
&intent.payment_method_id,
&invoice.id,
invoice.amount,
"usd",
)
.await
{
Ok(id) => id,
// Drop the attempt so the next pass retries cleanly on the tenant's
// current method, and record the failure to warn the user in the UI.
Err(error) => {
command::delete_intent(&intent.id).await?;
command::set_tenant_stripe_error(&tenant.pubkey, &format!("{error}")).await?;
return Err(error);
}
};
// 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
command::settle_invoice_via_intent(
&tenant.pubkey,
&intent.id,
&payment_intent_id,
&invoice.id,
)
.await
}
async fn attempt_payment_using_dm(
@@ -526,7 +534,7 @@ impl Billing {
/// 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 {
if let Err(error) = self.stripe.expire_checkout_session(&checkout.session_id).await {
tracing::debug!(
invoice = %invoice.id,
checkout = %checkout.id,
+74 -28
View File
@@ -5,8 +5,8 @@ use sqlx::{Sqlite, Transaction};
use crate::billing::BillingPeriod;
use crate::db::{pool, publish, with_tx};
use crate::models::{
Activity, Bolt11, Checkout, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT,
RELAY_STATUS_INACTIVE, Relay, Snapshot, Tenant,
Activity, Bolt11, Checkout, Intent, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE,
RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Snapshot, Tenant,
};
// --- Tenants ---
@@ -350,9 +350,8 @@ pub async fn mark_activity_billed(activity_id: &str) -> Result<()> {
// --- Invoices ---
/// Claim all of a tenant's outstanding items onto a new invoice. A non-positive
/// balance leaves the items outstanding so the credit carries to the next positive
/// invoice. Returns the invoice, or `None` when there's nothing to bill.
/// Claim a tenant's outstanding items onto a new invoice once the balance clears
/// the minimum.
pub async fn create_invoice(tenant: &Tenant, period: &BillingPeriod) -> Result<Option<Invoice>> {
with_tx(async |tx| {
let total = sqlx::query_scalar::<_, i64>(
@@ -363,7 +362,10 @@ pub async fn create_invoice(tenant: &Tenant, period: &BillingPeriod) -> Result<O
.fetch_one(&mut **tx)
.await?;
if total <= 0 {
// Stripe's minimum charge is $0.50 USD; $1 leaves margin so a later
// small credit can't drop a fresh invoice under that floor. Leave
// items outstanding and carry to a later invoice.
if total <= 100 {
return Ok(None);
}
@@ -411,16 +413,19 @@ pub async fn settle_invoice_via_nwc(
.await
}
/// Atomically record a Stripe-settled invoice: persist the PaymentIntent, clear
/// the tenant's stored Stripe error, and mark the invoice paid.
/// Atomically settle an invoice paid off-session: stamp the write-ahead intent
/// with the Stripe PaymentIntent that confirmed it, clear the tenant's stored
/// Stripe error, and mark the invoice paid. `intent_id` is our row id (from
/// [`insert_pending_intent`]); `payment_intent_id` is the Stripe `pi_…`.
pub async fn settle_invoice_via_intent(
tenant_pubkey: &str,
intent_id: &str,
payment_intent_id: &str,
invoice_id: &str,
) -> Result<()> {
with_tx(async |tx| {
clear_tenant_stripe_error_tx(tx, tenant_pubkey).await?;
insert_settled_intent_tx(tx, intent_id, invoice_id).await?;
mark_intent_settled_tx(tx, intent_id, payment_intent_id).await?;
mark_invoice_paid_tx(tx, invoice_id, "stripe").await?;
Ok(())
})
@@ -430,7 +435,7 @@ pub async fn settle_invoice_via_intent(
/// 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.
/// is our row id, not the Stripe Checkout Session id.
pub async fn settle_invoice_via_checkout(
tenant_pubkey: &str,
checkout_id: &str,
@@ -486,23 +491,24 @@ pub async fn insert_bolt11(
// --- 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.
/// [`Checkout`]. Mirrors [`insert_bolt11`]: created unsettled with our own id,
/// then stamped by [`settle_invoice_via_checkout`] once the session is paid.
pub async fn insert_checkout(
invoice_id: &str,
session_id: &str,
url: &str,
expires_at: i64,
) -> Result<Option<Checkout>> {
let id = uuid::Uuid::new_v4().to_string();
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 *",
"INSERT INTO checkout (id, invoice_id, session_id, url, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?) RETURNING *",
)
.bind(session_id)
.bind(id)
.bind(invoice_id)
.bind(session_id)
.bind(url)
.bind(created_at)
.bind(expires_at)
@@ -778,24 +784,64 @@ async fn mark_checkout_settled_tx(
// --- Intents ---
/// 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,
) -> Result<()> {
/// Write-ahead an off-session charge attempt before confirming it with Stripe,
/// returning the stored [`Intent`]. Records the payment method so a retry after a
/// lost settle re-confirms the same (idempotent) PaymentIntent; settled later by
/// [`settle_invoice_via_intent`], or dropped by [`delete_intent`] if it declines.
///
/// Get-or-create, atomically: if the invoice already has an unsettled intent,
/// returns it unchanged (keeping its original `payment_method_id`, so the retry
/// re-confirms the same charge); otherwise inserts a fresh one. The partial
/// unique index on (invoice_id) WHERE settled_at IS NULL makes this race-free —
/// concurrent reconciles converge on one intent instead of two.
pub async fn ensure_pending_intent(invoice_id: &str, payment_method_id: &str) -> Result<Intent> {
let id = uuid::Uuid::new_v4().to_string();
let created_at = chrono::Utc::now().timestamp();
sqlx::query(
"INSERT INTO intent (id, invoice_id, created_at)
VALUES (?, ?, ?) ON CONFLICT(id) DO NOTHING",
Ok(sqlx::query_as::<_, Intent>(
"INSERT INTO intent (id, invoice_id, payment_method_id, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(invoice_id) WHERE settled_at IS NULL
DO UPDATE SET payment_method_id = intent.payment_method_id
RETURNING *",
)
.bind(intent_id)
.bind(id)
.bind(invoice_id)
.bind(payment_method_id)
.bind(created_at)
.fetch_one(pool())
.await?)
}
/// Stamp an off-session intent settled with the Stripe PaymentIntent that
/// confirmed it, but don't overwrite an existing settled_at — so reconciling the
/// same attempt twice is a no-op.
async fn mark_intent_settled_tx(
tx: &mut Transaction<'_, Sqlite>,
intent_id: &str,
payment_intent_id: &str,
) -> Result<()> {
let settled_at = chrono::Utc::now().timestamp();
sqlx::query(
"UPDATE intent SET settled_at = ?, payment_intent_id = ?
WHERE id = ? AND settled_at IS NULL",
)
.bind(settled_at)
.bind(payment_intent_id)
.bind(intent_id)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Drop a write-ahead intent whose charge didn't go through, so the next attempt
/// starts clean (on the tenant's current method). A charge that confirmed but
/// whose settle failed is instead left unsettled, for reconcile to re-confirm.
pub async fn delete_intent(intent_id: &str) -> Result<()> {
sqlx::query("DELETE FROM intent WHERE id = ?")
.bind(intent_id)
.execute(pool())
.await?;
Ok(())
}
+19 -3
View File
@@ -173,15 +173,31 @@ pub struct Bolt11 {
/// 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.
/// stamped `settled_at` once paid. `id` is our uuid; `session_id` is the Stripe
/// Checkout Session (`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 session_id: String,
pub url: String,
pub created_at: i64,
pub expires_at: i64,
pub settled_at: Option<i64>,
}
/// A write-ahead record of an off-session card charge: inserted before the Stripe
/// call and stamped `settled_at` once the charge confirms and the invoice is paid.
/// `payment_method_id` is the method it charges, so a retry after a lost settle
/// re-confirms the same (idempotent) PaymentIntent rather than charging a second
/// one; `payment_intent_id` is the Stripe `pi_…` that settled it.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Intent {
pub id: String,
pub invoice_id: String,
pub payment_method_id: String,
pub payment_intent_id: Option<String>,
pub created_at: i64,
pub settled_at: Option<i64>,
}