Fix a few bugs

This commit is contained in:
Jon Staab
2026-06-05 11:15:46 -07:00
parent 791a4fcb70
commit b5f3efc775
6 changed files with 61 additions and 56 deletions
+3
View File
@@ -42,6 +42,7 @@ CREATE TABLE IF NOT EXISTS relay (
blossom_enabled INTEGER NOT NULL DEFAULT 0, blossom_enabled INTEGER NOT NULL DEFAULT 0,
livekit_enabled INTEGER NOT NULL DEFAULT 0, livekit_enabled INTEGER NOT NULL DEFAULT 0,
push_enabled INTEGER NOT NULL DEFAULT 1, push_enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey) FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
); );
@@ -114,6 +115,8 @@ CREATE INDEX IF NOT EXISTS idx_activity_unbilled ON activity (tenant_pubkey, cre
CREATE INDEX IF NOT EXISTS idx_relay_tenant_pubkey ON relay (tenant_pubkey); CREATE INDEX IF NOT EXISTS idx_relay_tenant_pubkey ON relay (tenant_pubkey);
CREATE INDEX IF NOT EXISTS idx_relay_created ON relay (created_at);
CREATE INDEX IF NOT EXISTS idx_invoice_tenant_created ON invoice (tenant_pubkey, created_at); CREATE INDEX IF NOT EXISTS idx_invoice_tenant_created ON invoice (tenant_pubkey, created_at);
-- Dunning scans a tenant's still-open invoices oldest-first to retry payment. -- Dunning scans a tenant's still-open invoices oldest-first to retry payment.
+28 -41
View File
@@ -352,8 +352,6 @@ impl Billing {
autopay: bool, autopay: bool,
notify: bool, notify: bool,
) -> Result<()> { ) -> Result<()> {
let mut error_message: Option<String> = None;
// 1. Out-of-band lightning: settle a bolt11 paid out of band (e.g. a // 1. Out-of-band lightning: settle a bolt11 paid out of band (e.g. a
// manual QR scan, or an NWC pay that completed but failed to record). // manual QR scan, or an NWC pay that completed but failed to record).
// Checked before any charge so we never bill on top of it. // Checked before any charge so we never bill on top of it.
@@ -385,23 +383,24 @@ impl Billing {
} }
// 3. NWC auto-pay: if the tenant has configured an nwc_url, charge it. // 3. NWC auto-pay: if the tenant has configured an nwc_url, charge it.
if !tenant.nwc_url.is_empty() { if !tenant.nwc_url.is_empty()
match self.attempt_payment_using_nwc(tenant, invoice).await { && self
Ok(()) => return self.cleanup_pending_payments(invoice).await, .attempt_payment_using_nwc(tenant, invoice)
Err(e) => error_message = Some(format!("{e}")), .await
} .is_ok()
{
return self.cleanup_pending_payments(invoice).await;
} }
// 4. Payment method on file: charge the tenant's cached Stripe payment // 4. Payment method on file: charge the tenant's cached Stripe payment
// method, kept fresh by sync_stripe_payment_method before collection. // method, kept fresh by sync_stripe_payment_method before collection.
if let Some(payment_method) = &tenant.stripe_payment_method_id { if let Some(payment_method) = &tenant.stripe_payment_method_id
match self && self
.attempt_payment_using_stripe(tenant, invoice, payment_method) .attempt_payment_using_stripe(tenant, invoice, payment_method)
.await .await
{ .is_ok()
Ok(()) => return self.cleanup_pending_payments(invoice).await, {
Err(e) => error_message = error_message.or_else(|| Some(format!("{e}"))), return self.cleanup_pending_payments(invoice).await;
}
} }
if !notify { if !notify {
@@ -409,10 +408,7 @@ impl Billing {
} }
// 5. Manual payment: DM a link to the in-app payment page for this invoice. // 5. Manual payment: DM a link to the in-app payment page for this invoice.
if let Err(e) = self if let Err(e) = self.attempt_payment_using_dm(tenant, invoice).await {
.attempt_payment_using_dm(tenant, invoice, error_message)
.await
{
tracing::error!( tracing::error!(
tenant = %tenant.pubkey, tenant = %tenant.pubkey,
error = %e, error = %e,
@@ -482,12 +478,7 @@ impl Billing {
.await .await
} }
async fn attempt_payment_using_dm( async fn attempt_payment_using_dm(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> {
&self,
tenant: &Tenant,
invoice: &Invoice,
error: Option<String>,
) -> Result<()> {
let now = chrono::Utc::now().timestamp(); let now = chrono::Utc::now().timestamp();
// If the invoice was just generated, give the user a chance to check the dashboard before nagging them. // If the invoice was just generated, give the user a chance to check the dashboard before nagging them.
@@ -507,21 +498,10 @@ impl Billing {
let invoice_id = &invoice.id; let invoice_id = &invoice.id;
let url_base = &env::get().app_url; let url_base = &env::get().app_url;
let payment_url = format!("{url_base}/account?invoice={invoice_id}"); let payment_url = format!("{url_base}/account?invoice={invoice_id}");
let base = format!("{MANUAL_PAYMENT_DM}\n\n{payment_url}"); let message = format!("{MANUAL_PAYMENT_DM}\n\n{payment_url}");
let dm_message = match error {
Some(error) if !error.is_empty() => {
let limit: usize = 240;
let summary = error
.chars()
.take(limit.saturating_sub(3))
.collect::<String>();
format!("{base}\n\nAuto-payment failed: {summary}")
}
_ => base,
};
// Send via NIP 17 // Send via NIP 17
self.robot.send_dm(&tenant.pubkey, &dm_message).await?; self.robot.send_dm(&tenant.pubkey, &message).await?;
// Record the send to avoid spammy notifications. // Record the send to avoid spammy notifications.
command::mark_invoice_notified(invoice_id).await command::mark_invoice_notified(invoice_id).await
@@ -534,7 +514,11 @@ impl Billing {
/// transaction, so it's excluded here and not needlessly expired. /// transaction, so it's excluded here and not needlessly expired.
async fn cleanup_pending_payments(&self, invoice: &Invoice) -> Result<()> { async fn cleanup_pending_payments(&self, invoice: &Invoice) -> Result<()> {
for checkout in query::list_pending_checkouts_for_invoice(&invoice.id).await? { for checkout in query::list_pending_checkouts_for_invoice(&invoice.id).await? {
if let Err(error) = self.stripe.expire_checkout_session(&checkout.session_id).await { if let Err(error) = self
.stripe
.expire_checkout_session(&checkout.session_id)
.await
{
tracing::debug!( tracing::debug!(
invoice = %invoice.id, invoice = %invoice.id,
checkout = %checkout.id, checkout = %checkout.id,
@@ -561,7 +545,8 @@ impl Billing {
} }
if let Some(existing) = query::get_bolt11_for_invoice(&invoice.id).await? if let Some(existing) = query::get_bolt11_for_invoice(&invoice.id).await?
&& (existing.settled_at.is_none() || now < existing.expires_at) && existing.settled_at.is_none()
&& now < existing.expires_at
{ {
return Ok(existing); return Ok(existing);
} }
@@ -713,15 +698,17 @@ impl BillingPeriod {
/// Fraction of this period still unused at `at`, in `[0.0, 1.0]`, for /// Fraction of this period still unused at `at`, in `[0.0, 1.0]`, for
/// prorating a mid-period charge or credit. The remaining time is rounded to /// prorating a mid-period charge or credit. The remaining time is rounded to
/// the nearest hour so proration tracks whole hours rather than exact seconds. /// the nearest day so proration tracks whole days rather than exact seconds.
/// Rounding by the day keeps a relay created within the first half-day of a
/// period billed at the full plan amount instead of a few cents short.
fn fraction_remaining(&self, at: i64) -> f64 { fn fraction_remaining(&self, at: i64) -> f64 {
const HOUR: i64 = 60 * 60; const DAY: i64 = 24 * 60 * 60;
let len = (self.end - self.start) as f64; let len = (self.end - self.start) as f64;
if len <= 0.0 { if len <= 0.0 {
return 1.0; return 1.0;
} }
let remaining = ((self.end - at) + HOUR / 2) / HOUR * HOUR; let remaining = ((self.end - at) + DAY / 2) / DAY * DAY;
(remaining as f64 / len).clamp(0.0, 1.0) (remaining as f64 / len).clamp(0.0, 1.0)
} }
+4 -2
View File
@@ -146,6 +146,7 @@ pub async fn reactivate_tenant(tenant_pubkey: &str, relays: &[Relay]) -> Result<
// --- Relays --- // --- Relays ---
pub async fn create_relay(relay: &Relay) -> Result<()> { pub async fn create_relay(relay: &Relay) -> Result<()> {
let created_at = chrono::Utc::now().timestamp();
let activity = with_tx(async |tx| { let activity = with_tx(async |tx| {
sqlx::query( sqlx::query(
"INSERT INTO relay ( "INSERT INTO relay (
@@ -153,8 +154,8 @@ pub async fn create_relay(relay: &Relay) -> Result<()> {
info_name, info_icon, info_description, info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures, policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled, groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled livekit_enabled, push_enabled, created_at
) VALUES (?, ?, ?, ?, 'active', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) VALUES (?, ?, ?, ?, 'active', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
) )
.bind(&relay.id) .bind(&relay.id)
.bind(&relay.tenant_pubkey) .bind(&relay.tenant_pubkey)
@@ -171,6 +172,7 @@ pub async fn create_relay(relay: &Relay) -> Result<()> {
.bind(relay.blossom_enabled) .bind(relay.blossom_enabled)
.bind(relay.livekit_enabled) .bind(relay.livekit_enabled)
.bind(relay.push_enabled) .bind(relay.push_enabled)
.bind(created_at)
.execute(&mut **tx) .execute(&mut **tx)
.await?; .await?;
let snapshot = Snapshot::Relay { let snapshot = Snapshot::Relay {
+2
View File
@@ -91,6 +91,7 @@ pub struct Relay {
pub livekit_enabled: i64, pub livekit_enabled: i64,
pub push_enabled: i64, pub push_enabled: i64,
pub synced: i64, pub synced: i64,
pub created_at: i64,
} }
impl Default for Relay { impl Default for Relay {
@@ -113,6 +114,7 @@ impl Default for Relay {
livekit_enabled: 0, livekit_enabled: 0,
push_enabled: 1, push_enabled: 1,
synced: 0, synced: 0,
created_at: 0,
} }
} }
} }
+16 -12
View File
@@ -56,9 +56,11 @@ pub fn get_plan(plan_id: &str) -> Result<Plan> {
// --- Tenants --- // --- Tenants ---
pub async fn list_tenants() -> Result<Vec<Tenant>> { pub async fn list_tenants() -> Result<Vec<Tenant>> {
Ok(sqlx::query_as::<_, Tenant>(&select_tenant("")) Ok(
.fetch_all(pool()) sqlx::query_as::<_, Tenant>(&select_tenant("ORDER BY created_at DESC"))
.await?) .fetch_all(pool())
.await?,
)
} }
pub async fn get_tenant(pubkey: &str) -> Result<Option<Tenant>> { pub async fn get_tenant(pubkey: &str) -> Result<Option<Tenant>> {
@@ -73,9 +75,11 @@ pub async fn get_tenant(pubkey: &str) -> Result<Option<Tenant>> {
// --- Relays --- // --- Relays ---
pub async fn list_relays() -> Result<Vec<Relay>> { pub async fn list_relays() -> Result<Vec<Relay>> {
Ok(sqlx::query_as::<_, Relay>(&select_relay("")) Ok(
.fetch_all(pool()) sqlx::query_as::<_, Relay>(&select_relay("ORDER BY created_at DESC"))
.await?) .fetch_all(pool())
.await?,
)
} }
pub async fn list_relays_pending_sync() -> Result<Vec<Relay>> { pub async fn list_relays_pending_sync() -> Result<Vec<Relay>> {
@@ -87,12 +91,12 @@ pub async fn list_relays_pending_sync() -> Result<Vec<Relay>> {
} }
pub async fn list_relays_for_tenant(tenant_pubkey: &str) -> Result<Vec<Relay>> { pub async fn list_relays_for_tenant(tenant_pubkey: &str) -> Result<Vec<Relay>> {
Ok( Ok(sqlx::query_as::<_, Relay>(&select_relay(
sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant_pubkey = ?")) "WHERE tenant_pubkey = ? ORDER BY created_at DESC",
.bind(tenant_pubkey) ))
.fetch_all(pool()) .bind(tenant_pubkey)
.await?, .fetch_all(pool())
) .await?)
} }
pub async fn get_relay(id: &str) -> Result<Option<Relay>> { pub async fn get_relay(id: &str) -> Result<Option<Relay>> {
+8 -1
View File
@@ -29,6 +29,13 @@ export default function BillingPrompts(props: BillingPromptsProps) {
(id) => getInvoice(id), (id) => getInvoice(id),
) )
// A resource keeps its last value once its source goes falsy, so deepLinked()
// still returns the fetched invoice after the param is cleared. Gate on the
// param so clearing it actually closes the dialog (otherwise it can't be).
const deepLinkedInvoice = createMemo(() =>
searchParams.invoice ? deepLinked() : undefined,
)
const prompt = createMemo(() => const prompt = createMemo(() =>
activeBillingPrompt( activeBillingPrompt(
{ {
@@ -108,7 +115,7 @@ export default function BillingPrompts(props: BillingPromptsProps) {
</Show> </Show>
{/* Pay an invoice — from a prompt action or a deep link. */} {/* Pay an invoice — from a prompt action or a deep link. */}
<Show when={payInvoice() ?? deepLinked()}> <Show when={payInvoice() ?? deepLinkedInvoice()}>
{(invoice) => ( {(invoice) => (
<PaymentDialog <PaymentDialog
invoice={invoice()} invoice={invoice()}