Files
caravel/backend/src/command.rs
T
Jon Staab 43eaad1621
Docker / build-and-push-image (push) Successful in 52m8s
Differentiate checkout id/session id
2026-06-03 14:27:57 -07:00

848 lines
27 KiB
Rust

use anyhow::Result;
use sqlx::types::Json;
use sqlx::{Sqlite, Transaction};
use crate::billing::BillingPeriod;
use crate::db::{pool, publish, with_tx};
use crate::models::{
Activity, Bolt11, Checkout, Intent, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE,
RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay, Snapshot, Tenant,
};
// --- Tenants ---
pub async fn create_tenant(tenant: &Tenant) -> Result<()> {
sqlx::query(
"INSERT INTO tenant (pubkey, nwc_url, created_at, stripe_customer_id)
VALUES (?, ?, ?, ?)",
)
.bind(&tenant.pubkey)
.bind(&tenant.nwc_url)
.bind(tenant.created_at)
.bind(&tenant.stripe_customer_id)
.execute(pool())
.await?;
Ok(())
}
/// Update a tenant's NWC credentials, clearing any stored NWC error so a fresh
/// wallet starts from a clean slate (it re-errors on the next charge if invalid).
pub async fn update_tenant(tenant: &Tenant) -> Result<()> {
sqlx::query("UPDATE tenant SET nwc_url = ?, nwc_error = NULL WHERE pubkey = ?")
.bind(&tenant.nwc_url)
.bind(&tenant.pubkey)
.execute(pool())
.await?;
Ok(())
}
/// Cache the tenant's Stripe payment method id (or clear it with `None`) and clear
/// any stored Stripe error. Called when a card is (re)attached via the portal or
/// detected during reconciliation, so collection can charge it directly and the UI
/// reflects the change.
pub async fn set_tenant_stripe_payment_method(
pubkey: &str,
payment_method_id: &Option<String>,
) -> Result<()> {
sqlx::query(
"UPDATE tenant SET stripe_payment_method_id = ?, stripe_error = NULL WHERE pubkey = ?",
)
.bind(payment_method_id)
.bind(pubkey)
.execute(pool())
.await?;
Ok(())
}
pub async fn set_tenant_billing_anchor(tenant: &Tenant) -> Result<()> {
sqlx::query("UPDATE tenant SET billing_anchor = ? WHERE pubkey = ?")
.bind(tenant.billing_anchor)
.bind(&tenant.pubkey)
.execute(pool())
.await?;
Ok(())
}
pub async fn set_tenant_nwc_error(pubkey: &str, error: &str) -> Result<()> {
sqlx::query("UPDATE tenant SET nwc_error = ? WHERE pubkey = ?")
.bind(error)
.bind(pubkey)
.execute(pool())
.await?;
Ok(())
}
pub async fn set_tenant_stripe_error(pubkey: &str, error: &str) -> Result<()> {
sqlx::query("UPDATE tenant SET stripe_error = ? WHERE pubkey = ?")
.bind(error)
.bind(pubkey)
.execute(pool())
.await?;
Ok(())
}
/// Atomically churn a tenant whose grace period has elapsed: set the churn
/// marker, mark every active relay delinquent, and void the unpaid invoices.
pub async fn churn_tenant(tenant_pubkey: &str, now: i64, relays: &[Relay]) -> Result<()> {
let activities = with_tx(async |tx| {
set_tenant_churned_at_tx(tx, tenant_pubkey, Some(now)).await?;
let mut activities = Vec::new();
for relay in relays {
if relay.status == RELAY_STATUS_ACTIVE {
let activity = set_relay_status_tx(
tx,
relay,
RELAY_STATUS_DELINQUENT,
"mark_relay_delinquent",
)
.await?;
activities.push(activity);
}
}
void_open_invoices_tx(tx, tenant_pubkey).await?;
Ok(activities)
})
.await?;
for activity in activities {
publish(activity);
}
Ok(())
}
/// Atomically re-activate a churned tenant: clear the churn marker, restore every
/// delinquent relay to active, and void any still-open invoices. Returns the
/// `unmark_relay_delinquent` activities recorded for the restored relays, so the
/// caller can fold their prorated charges into the same reconcile pass.
pub async fn reactivate_tenant(tenant_pubkey: &str, relays: &[Relay]) -> Result<Vec<Activity>> {
let activities = with_tx(async |tx| {
set_tenant_churned_at_tx(tx, tenant_pubkey, None).await?;
let mut activities = Vec::new();
for relay in relays {
if relay.status == RELAY_STATUS_DELINQUENT {
let activity =
set_relay_status_tx(tx, relay, RELAY_STATUS_ACTIVE, "unmark_relay_delinquent")
.await?;
activities.push(activity);
}
}
void_open_invoices_tx(tx, tenant_pubkey).await?;
Ok(activities)
})
.await?;
for activity in &activities {
publish(activity.clone());
}
Ok(activities)
}
// --- Relays ---
pub async fn create_relay(relay: &Relay) -> Result<()> {
let activity = with_tx(async |tx| {
sqlx::query(
"INSERT INTO relay (
id, tenant_pubkey, subdomain, plan_id, status, synced, sync_error,
info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled
) VALUES (?, ?, ?, ?, 'active', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&relay.id)
.bind(&relay.tenant_pubkey)
.bind(&relay.subdomain)
.bind(&relay.plan_id)
.bind(&relay.sync_error)
.bind(&relay.info_name)
.bind(&relay.info_icon)
.bind(&relay.info_description)
.bind(relay.policy_public_join)
.bind(relay.policy_strip_signatures)
.bind(relay.groups_enabled)
.bind(relay.management_enabled)
.bind(relay.blossom_enabled)
.bind(relay.livekit_enabled)
.bind(relay.push_enabled)
.execute(&mut **tx)
.await?;
let snapshot = Snapshot::Relay {
plan: relay.plan_id.clone(),
status: RELAY_STATUS_ACTIVE.to_string(),
};
insert_activity_tx(tx, "create_relay", &relay.id, snapshot).await
})
.await?;
publish(activity);
Ok(())
}
pub async fn update_relay(relay: &Relay) -> Result<()> {
let activity = with_tx(async |tx| {
sqlx::query(
"UPDATE relay
SET tenant_pubkey = ?, subdomain = ?, plan_id = ?, status = ?, sync_error = ?, synced = 0,
info_name = ?, info_icon = ?, info_description = ?,
policy_public_join = ?, policy_strip_signatures = ?,
groups_enabled = ?, management_enabled = ?, blossom_enabled = ?,
livekit_enabled = ?, push_enabled = ?
WHERE id = ?",
)
.bind(&relay.tenant_pubkey)
.bind(&relay.subdomain)
.bind(&relay.plan_id)
.bind(&relay.status)
.bind(&relay.sync_error)
.bind(&relay.info_name)
.bind(&relay.info_icon)
.bind(&relay.info_description)
.bind(relay.policy_public_join)
.bind(relay.policy_strip_signatures)
.bind(relay.groups_enabled)
.bind(relay.management_enabled)
.bind(relay.blossom_enabled)
.bind(relay.livekit_enabled)
.bind(relay.push_enabled)
.bind(&relay.id)
.execute(&mut **tx)
.await?;
let snapshot = Snapshot::Relay {
plan: relay.plan_id.clone(),
status: relay.status.clone(),
};
insert_activity_tx(tx, "update_relay", &relay.id, snapshot).await
})
.await?;
publish(activity);
Ok(())
}
pub async fn activate_relay(relay: &Relay) -> Result<()> {
set_relay_status(relay, RELAY_STATUS_ACTIVE, "activate_relay").await
}
pub async fn deactivate_relay(relay: &Relay) -> Result<()> {
set_relay_status(relay, RELAY_STATUS_INACTIVE, "deactivate_relay").await
}
async fn set_relay_status(relay: &Relay, status: &str, activity_type: &str) -> Result<()> {
let activity =
with_tx(async |tx| set_relay_status_tx(tx, relay, status, activity_type).await).await?;
publish(activity);
Ok(())
}
pub async fn fail_relay_sync(relay: &Relay, sync_error: String) -> Result<()> {
let activity = with_tx(async |tx| {
sqlx::query("UPDATE relay SET synced = 0, sync_error = ? WHERE id = ?")
.bind(&sync_error)
.bind(&relay.id)
.execute(&mut **tx)
.await?;
let snapshot = Snapshot::Relay {
plan: relay.plan_id.clone(),
status: relay.status.clone(),
};
insert_activity_tx(tx, "fail_relay_sync", &relay.id, snapshot).await
})
.await?;
publish(activity);
Ok(())
}
pub async fn complete_relay_sync(relay: &Relay) -> Result<()> {
let activity = with_tx(async |tx| {
sqlx::query("UPDATE relay SET synced = 1, sync_error = '' WHERE id = ?")
.bind(&relay.id)
.execute(&mut **tx)
.await?;
let snapshot = Snapshot::Relay {
plan: relay.plan_id.clone(),
status: relay.status.clone(),
};
insert_activity_tx(tx, "complete_relay_sync", &relay.id, snapshot).await
})
.await?;
publish(activity);
Ok(())
}
// --- Invoice items ---
/// Persist a reconciled activity's line item and mark the activity billed in one
/// transaction, so a recovery pass never re-bills it.
pub async fn insert_invoice_item_for_activity(
invoice_item: &InvoiceItem,
activity_id: &str,
) -> Result<()> {
let now = chrono::Utc::now().timestamp();
with_tx(async |tx| {
// Claim the activity first. If a concurrent reconcile pass already billed
// it, the claim no-ops and we skip the item rather than duplicating it.
if mark_activity_billed_tx(tx, activity_id, now).await? {
insert_invoice_item_tx(tx, invoice_item).await?;
}
Ok(())
})
.await
}
/// Insert this period's renewal items and advance the tenant's `renewed_at`
/// marker to `period.start`, atomically and idempotently. Empty `items` is a
/// no-op — a tenant with no active paid relays has nothing to renew.
pub async fn insert_invoice_items_for_renewal(
items: &[InvoiceItem],
period: &BillingPeriod,
) -> Result<()> {
let Some(first) = items.first() else {
return Ok(());
};
let tenant_pubkey = &first.tenant_pubkey;
with_tx(async |tx| {
// Re-read the marker inside the transaction so the guard and the writes
// commit together — this ensures idempotency so we don't double-invoice.
let renewed_at =
sqlx::query_scalar::<_, Option<i64>>("SELECT renewed_at FROM tenant WHERE pubkey = ?")
.bind(tenant_pubkey)
.fetch_one(&mut **tx)
.await?;
if renewed_at.is_some_and(|at| at >= period.start) {
return Ok(());
}
for item in items {
insert_invoice_item_tx(tx, item).await?;
}
sqlx::query("UPDATE tenant SET renewed_at = ? WHERE pubkey = ?")
.bind(period.start)
.bind(tenant_pubkey)
.execute(&mut **tx)
.await?;
Ok(())
})
.await
}
/// Mark an activity billed without a line item — for activities that produce no
/// charge (e.g. free-plan changes), so a recovery pass doesn't re-scan them.
pub async fn mark_activity_billed(activity_id: &str) -> Result<()> {
let now = chrono::Utc::now().timestamp();
with_tx(async |tx| {
mark_activity_billed_tx(tx, activity_id, now).await?;
Ok(())
})
.await
}
// --- Invoices ---
/// 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>(
"SELECT COALESCE(SUM(amount), 0) FROM invoice_item
WHERE tenant_pubkey = ? AND invoice_id IS NULL AND voided_at IS NULL",
)
.bind(&tenant.pubkey)
.fetch_one(&mut **tx)
.await?;
// 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);
}
let invoice = insert_invoice_tx(tx, tenant, period, total).await?;
sqlx::query(
"UPDATE invoice_item SET invoice_id = ?
WHERE tenant_pubkey = ? AND invoice_id IS NULL AND voided_at IS NULL",
)
.bind(&invoice.id)
.bind(&tenant.pubkey)
.execute(&mut **tx)
.await?;
Ok(Some(invoice))
})
.await
}
// --- 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(
tenant_pubkey: &str,
bolt11_id: &str,
invoice_id: &str,
) -> Result<()> {
with_tx(async |tx| {
clear_tenant_nwc_error_tx(tx, tenant_pubkey).await?;
mark_bolt11_settled_tx(tx, bolt11_id).await?;
mark_invoice_paid_tx(tx, invoice_id, "nwc").await?;
Ok(())
})
.await
}
/// 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?;
mark_intent_settled_tx(tx, intent_id, payment_intent_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 our row id, not the Stripe Checkout Session id.
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(())
})
.await
}
/// Stamp an invoice with the time its manual-payment reminder DM was sent, so
/// dunning sends that DM once instead of on every hourly poll.
pub async fn mark_invoice_notified(invoice_id: &str) -> Result<()> {
let notified_at = chrono::Utc::now().timestamp();
sqlx::query("UPDATE invoice SET notified_at = ? WHERE id = ?")
.bind(notified_at)
.bind(invoice_id)
.execute(pool())
.await?;
Ok(())
}
// --- Bolt11 records ---
pub async fn insert_bolt11(
invoice_id: &str,
lnbc: &str,
msats: i64,
expires_at: i64,
) -> Result<Option<Bolt11>> {
let id = uuid::Uuid::new_v4().to_string();
let created_at = chrono::Utc::now().timestamp();
Ok(sqlx::query_as::<_, Bolt11>(
"INSERT INTO bolt11 (id, invoice_id, lnbc, msats, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?) RETURNING *",
)
.bind(id)
.bind(invoice_id)
.bind(lnbc)
.bind(msats)
.bind(created_at)
.bind(expires_at)
.fetch_optional(pool())
.await?)
}
// --- Checkout records ---
/// Record a pending Stripe Checkout session for an invoice, returning the stored
/// [`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, session_id, url, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?) RETURNING *",
)
.bind(id)
.bind(invoice_id)
.bind(session_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,
resource_id: &str,
snapshot: Snapshot,
) -> Result<Activity> {
let resource_type = snapshot.resource_type();
let tenant_pubkey = match &snapshot {
Snapshot::Relay { .. } => {
sqlx::query_scalar::<_, String>("SELECT tenant_pubkey FROM relay WHERE id = ?")
.bind(resource_id)
.fetch_one(&mut **tx)
.await?
}
};
let id = uuid::Uuid::new_v4().to_string();
let created_at = chrono::Utc::now().timestamp();
let snapshot = Json(snapshot);
sqlx::query(
"INSERT INTO activity (id, tenant_pubkey, created_at, activity_type, resource_type, resource_id, snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(&tenant_pubkey)
.bind(created_at)
.bind(activity_type)
.bind(resource_type)
.bind(resource_id)
.bind(&snapshot)
.execute(&mut **tx)
.await?;
Ok(Activity {
id,
tenant_pubkey,
created_at,
activity_type: activity_type.to_string(),
resource_type: resource_type.to_string(),
resource_id: resource_id.to_string(),
billed_at: None,
snapshot,
})
}
/// 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,
period: &BillingPeriod,
amount: i64,
) -> Result<Invoice> {
let now = chrono::Utc::now().timestamp();
let invoice_id = uuid::Uuid::new_v4().to_string();
Ok(sqlx::query_as::<_, Invoice>(
"INSERT INTO invoice (id, tenant_pubkey, amount, period_start, period_end, created_at)
VALUES (?, ?, ?, ?, ?, ?) RETURNING *",
)
.bind(invoice_id)
.bind(&tenant.pubkey)
.bind(amount)
.bind(period.start)
.bind(period.end)
.bind(now)
.fetch_one(&mut **tx)
.await?)
}
async fn insert_invoice_item_tx(
tx: &mut Transaction<'_, Sqlite>,
item: &InvoiceItem,
) -> Result<()> {
sqlx::query(
"INSERT INTO invoice_item
(id, invoice_id, activity_id, tenant_pubkey, relay_id, plan_id, amount, description, created_at, voided_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&item.id)
.bind(&item.invoice_id)
.bind(&item.activity_id)
.bind(&item.tenant_pubkey)
.bind(&item.relay_id)
.bind(&item.plan_id)
.bind(item.amount)
.bind(&item.description)
.bind(item.created_at)
.bind(item.voided_at)
.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.
async fn mark_invoice_paid_tx(
tx: &mut Transaction<'_, Sqlite>,
invoice_id: &str,
method: &str,
) -> Result<()> {
let paid_at = chrono::Utc::now().timestamp();
sqlx::query(
"UPDATE invoice SET method = ?, paid_at = ?
WHERE id = ? AND paid_at IS NULL AND voided_at IS NULL",
)
.bind(method)
.bind(paid_at)
.bind(invoice_id)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Void all of a tenant's open invoices and unpaid line items, forgiving the
/// balance — used when a tenant churns or re-activates, so old debt never has to
/// be collected. Voiding the items too (both outstanding ones and those on the
/// just-voided invoices) keeps a credit from bleeding into a future invoice and
/// lets a re-billed period start from a clean ledger. Items on a paid invoice are
/// left untouched.
async fn void_open_invoices_tx(
tx: &mut Transaction<'_, Sqlite>,
tenant_pubkey: &str,
) -> Result<()> {
let voided_at = chrono::Utc::now().timestamp();
sqlx::query(
"UPDATE invoice SET voided_at = ?
WHERE tenant_pubkey = ? AND paid_at IS NULL AND voided_at IS NULL",
)
.bind(voided_at)
.bind(tenant_pubkey)
.execute(&mut **tx)
.await?;
// Run after voiding the invoices above, so the `paid_at IS NULL` subquery
// catches their now-voided items along with the still-outstanding ones.
sqlx::query(
"UPDATE invoice_item SET voided_at = ?
WHERE tenant_pubkey = ? AND voided_at IS NULL
AND (invoice_id IS NULL OR invoice_id IN (
SELECT id FROM invoice WHERE paid_at IS NULL
))",
)
.bind(voided_at)
.bind(tenant_pubkey)
.execute(&mut **tx)
.await?;
Ok(())
}
// --- 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>,
checkout_id: &str,
) -> Result<()> {
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(())
}
// --- Intents ---
/// 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();
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(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(())
}