Massive user-story-oriented refactor
This commit is contained in:
+10
-8
@@ -18,8 +18,7 @@ use std::sync::Arc;
|
||||
|
||||
use anyhow::{Result, anyhow, ensure};
|
||||
use axum::{
|
||||
Router,
|
||||
async_trait,
|
||||
Router, async_trait,
|
||||
extract::FromRequestParts,
|
||||
http::{HeaderMap, request::Parts},
|
||||
routing::{get, post},
|
||||
@@ -32,9 +31,8 @@ use crate::env;
|
||||
use crate::models::{Relay, Tenant};
|
||||
use crate::query;
|
||||
use crate::robot::Robot;
|
||||
use crate::stripe::Stripe;
|
||||
use crate::routes::identity::get_identity;
|
||||
use crate::routes::invoices::{get_invoice, get_invoice_bolt11, get_tenant_latest_invoice};
|
||||
use crate::routes::invoices::{get_invoice, get_invoice_bolt11, list_invoice_items};
|
||||
use crate::routes::plans::{get_plan, list_plans};
|
||||
use crate::routes::relays::{
|
||||
create_relay, deactivate_relay, get_relay, list_relay_activity, list_relay_members,
|
||||
@@ -44,6 +42,7 @@ use crate::routes::tenants::{
|
||||
create_stripe_session, create_tenant, get_tenant, list_tenant_invoices, list_tenant_relays,
|
||||
list_tenants, update_tenant,
|
||||
};
|
||||
use crate::stripe::Stripe;
|
||||
use crate::web::{ApiError, forbidden, internal, not_found, unauthorized};
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -74,10 +73,9 @@ impl Api {
|
||||
.route("/tenants/:pubkey/relays", get(list_tenant_relays))
|
||||
.route("/tenants/:pubkey/invoices", get(list_tenant_invoices))
|
||||
.route(
|
||||
"/tenants/:pubkey/invoices/latest",
|
||||
get(get_tenant_latest_invoice),
|
||||
"/tenants/:pubkey/stripe/session",
|
||||
get(create_stripe_session),
|
||||
)
|
||||
.route("/tenants/:pubkey/stripe/session", get(create_stripe_session))
|
||||
.route("/relays", get(list_relays).post(create_relay))
|
||||
.route("/relays/:id", get(get_relay).put(update_relay))
|
||||
.route("/relays/:id/members", get(list_relay_members))
|
||||
@@ -86,6 +84,7 @@ impl Api {
|
||||
.route("/relays/:id/reactivate", post(reactivate_relay))
|
||||
.route("/invoices/:id", get(get_invoice))
|
||||
.route("/invoices/:id/bolt11", get(get_invoice_bolt11))
|
||||
.route("/invoices/:id/items", get(list_invoice_items))
|
||||
.with_state(api)
|
||||
}
|
||||
|
||||
@@ -185,7 +184,10 @@ impl Api {
|
||||
.last()
|
||||
.ok_or_else(|| anyhow!("missing u tag"))?;
|
||||
|
||||
ensure!(got_u == env::get().server_host, "authorization host mismatch");
|
||||
ensure!(
|
||||
got_u == env::get().server_host,
|
||||
"authorization host mismatch"
|
||||
);
|
||||
|
||||
Ok(event.pubkey.to_hex())
|
||||
}
|
||||
|
||||
+110
-66
@@ -14,9 +14,12 @@ use crate::wallet::Wallet;
|
||||
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(60 * 60);
|
||||
const GRACE_PERIOD_SECS: i64 = 7 * 24 * 60 * 60;
|
||||
/// Hold the manual-payment DM until an open invoice is at least this old. A freshly
|
||||
/// issued invoice is surfaced to the tenant in-app first (e.g. right after they
|
||||
/// create a relay), so we don't also nag by DM on the first dunning cycles.
|
||||
const FRESH_INVOICE_DM_GRACE_SECS: i64 = 24 * 60 * 60;
|
||||
const MANUAL_PAYMENT_DM: &str = "Payment is due for your relay subscription. Open the link below to review the invoice and pay by Lightning or card:";
|
||||
const USER_ERROR_PREFIX: &str = "Auto-payment failed:";
|
||||
const USER_ERROR_MAX_CHARS: usize = 240;
|
||||
const CHURN_DM: &str = "Your relay subscription is past due, so your relays have been paused. You can restore service any time by adding a payment method or paying an invoice from your dashboard:";
|
||||
|
||||
/// Owns subscription billing: it reconciles tenant activity into invoice items,
|
||||
/// renews subscriptions each period, and collects payment (Lightning, then a
|
||||
@@ -54,10 +57,13 @@ impl Billing {
|
||||
async fn reconcile_subscriptions(&self) -> Result<()> {
|
||||
let tenants = query::list_tenants().await?;
|
||||
|
||||
tracing::info!(tenant_count = tenants.len(), "reconciling all subscriptions");
|
||||
tracing::info!(
|
||||
tenant_count = tenants.len(),
|
||||
"reconciling all subscriptions"
|
||||
);
|
||||
|
||||
for tenant in tenants {
|
||||
if let Err(error) = self.reconcile_subscription(&tenant).await {
|
||||
if let Err(error) = self.reconcile_subscription(&tenant, true).await {
|
||||
tracing::error!(
|
||||
tenant = %tenant.pubkey,
|
||||
error = ?error,
|
||||
@@ -73,10 +79,13 @@ impl Billing {
|
||||
|
||||
/// Reconciles a tenant's billing: re-activates them if a churned tenant has
|
||||
/// re-engaged, folds billable activity into line items (setting the billing
|
||||
/// anchor on the first), renews the current period if due, claims outstanding
|
||||
/// items onto an invoice, and then collects every open invoice — churning the
|
||||
/// tenant if one has gone unpaid past the grace period.
|
||||
pub async fn reconcile_subscription(&self, tenant: &Tenant) -> Result<()> {
|
||||
/// anchor based on the first one), renews the current period if due, and claims
|
||||
/// outstanding items onto an invoice.
|
||||
pub async fn reconcile_subscription(
|
||||
&self,
|
||||
tenant: &Tenant,
|
||||
attempt_payment: bool,
|
||||
) -> Result<()> {
|
||||
let mut tenant = tenant.clone();
|
||||
|
||||
let activities = query::list_billable_activity(&tenant.pubkey).await?;
|
||||
@@ -110,9 +119,11 @@ impl Billing {
|
||||
command::create_invoice(&tenant, &period).await?;
|
||||
}
|
||||
|
||||
// Retry payment on every open invoice (this also pays one just created),
|
||||
// churning the tenant if the oldest has aged past the grace period.
|
||||
self.collect_open_invoices(&tenant).await?;
|
||||
// Attempt payment on every open invoice after syncing with stripe.
|
||||
if attempt_payment {
|
||||
self.sync_stripe_customer(&mut tenant).await?;
|
||||
self.collect_open_invoices(&tenant).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -140,7 +151,7 @@ impl Billing {
|
||||
};
|
||||
|
||||
match invoice_item {
|
||||
Some(ref item) => command::insert_invoice_item_for_activity(&item, &activity.id).await,
|
||||
Some(ref item) => command::insert_invoice_item_for_activity(item, &activity.id).await,
|
||||
None => command::mark_activity_billed(&activity.id).await,
|
||||
}
|
||||
}
|
||||
@@ -242,7 +253,11 @@ impl Billing {
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Snapshot::Relay { plan: plan_id, status, .. } = &*activity.snapshot;
|
||||
let Snapshot::Relay {
|
||||
plan: plan_id,
|
||||
status,
|
||||
..
|
||||
} = &*activity.snapshot;
|
||||
if status != RELAY_STATUS_ACTIVE {
|
||||
continue;
|
||||
}
|
||||
@@ -270,6 +285,37 @@ impl Billing {
|
||||
|
||||
// --- Payments ---
|
||||
|
||||
/// Dunning pass over a tenant's open invoices: if the oldest has been unpaid
|
||||
/// past the grace period, churn the tenant; otherwise retry payment on each.
|
||||
async fn collect_open_invoices(&self, tenant: &Tenant) -> Result<()> {
|
||||
let open = query::list_open_invoices(&tenant.pubkey).await?;
|
||||
let Some(oldest) = open.first() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
if now - oldest.created_at >= GRACE_PERIOD_SECS {
|
||||
if tenant.churned_at.is_none() {
|
||||
let relays = query::list_relays_for_tenant(&tenant.pubkey).await?;
|
||||
command::churn_tenant(&tenant.pubkey, now, &relays).await?;
|
||||
|
||||
// Notify the tenant once, on the transition into churn (the guard
|
||||
// above fires this a single time). Log-and-continue on failure.
|
||||
let message = format!("{CHURN_DM}\n\n{}/account", env::get().app_url);
|
||||
if let Err(e) = self.robot.send_dm(&tenant.pubkey, &message).await {
|
||||
tracing::error!(tenant = %tenant.pubkey, error = %e, "failed to send churn DM");
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for invoice in &open {
|
||||
self.attempt_payment(tenant, invoice).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Collect an invoice via NWC, then a saved card, then a manual DM. A failing
|
||||
/// method's error is stored on the tenant (to warn them in the UI) but never
|
||||
/// aborts the cascade or future retries; a method's error is cleared when it
|
||||
@@ -294,12 +340,11 @@ impl Billing {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 3. Payment method on file: if the tenant has one saved, charge it via Stripe.
|
||||
if let Some(payment_method) =
|
||||
self.stripe.get_saved_payment_method(&tenant.stripe_customer_id).await?
|
||||
{
|
||||
// 3. Payment method on file: charge the tenant's cached Stripe payment
|
||||
// method, kept fresh by sync_stripe_payment_method before collection.
|
||||
if let Some(payment_method) = &tenant.stripe_payment_method_id {
|
||||
match self
|
||||
.attempt_payment_using_stripe(tenant, invoice, &payment_method)
|
||||
.attempt_payment_using_stripe(tenant, invoice, payment_method)
|
||||
.await
|
||||
{
|
||||
Ok(()) => return Ok(()),
|
||||
@@ -308,8 +353,10 @@ impl Billing {
|
||||
}
|
||||
|
||||
// 4. Manual payment: DM a link to the in-app payment page for this invoice.
|
||||
let summary = error_message.as_deref().and_then(summarize_error_message);
|
||||
if let Err(e) = self.attempt_payment_using_dm(tenant, invoice, summary).await {
|
||||
if let Err(e) = self
|
||||
.attempt_payment_using_dm(tenant, invoice, error_message)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
tenant = %tenant.pubkey,
|
||||
error = %e,
|
||||
@@ -375,15 +422,23 @@ impl Billing {
|
||||
&self,
|
||||
tenant: &Tenant,
|
||||
invoice: &Invoice,
|
||||
error_message: Option<String>,
|
||||
error: Option<String>,
|
||||
) -> Result<()> {
|
||||
// If the invoice was just generated, give the user a chance to check the dashboard before nagging them.
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
if now - invoice.created_at < FRESH_INVOICE_DM_GRACE_SECS {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let invoice_id = &invoice.id;
|
||||
let url_base = &env::get().app_url;
|
||||
let payment_url = format!("{url_base}/account?invoice={invoice_id}");
|
||||
let base = format!("{MANUAL_PAYMENT_DM}\n\n{payment_url}");
|
||||
let dm_message = match error_message {
|
||||
Some(error_message) if !error_message.is_empty() => {
|
||||
format!("{base}\n\n{USER_ERROR_PREFIX} {error_message}")
|
||||
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,
|
||||
};
|
||||
@@ -391,32 +446,6 @@ impl Billing {
|
||||
self.robot.send_dm(&tenant.pubkey, &dm_message).await
|
||||
}
|
||||
|
||||
// --- Dunning ---
|
||||
|
||||
/// Dunning pass over a tenant's open invoices: if the oldest has been unpaid
|
||||
/// past the grace period, churn the tenant; otherwise retry payment on each.
|
||||
async fn collect_open_invoices(&self, tenant: &Tenant) -> Result<()> {
|
||||
let open = query::list_open_invoices(&tenant.pubkey).await?;
|
||||
let Some(oldest) = open.first() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
if now - oldest.created_at >= GRACE_PERIOD_SECS {
|
||||
if tenant.churned_at.is_none() {
|
||||
let relays = query::list_relays_for_tenant(&tenant.pubkey).await?;
|
||||
command::churn_tenant(&tenant.pubkey, now, &relays).await?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for invoice in &open {
|
||||
self.attempt_payment(tenant, invoice).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Bolt11 utils ---
|
||||
|
||||
pub async fn ensure_bolt11(&self, invoice: &Invoice) -> Result<Bolt11> {
|
||||
@@ -455,6 +484,37 @@ impl Billing {
|
||||
Ok(bolt11)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Stripe utils ---
|
||||
|
||||
/// Copy down any stripe-related stuff to our local tenant model. Fail gracefully.
|
||||
pub async fn sync_stripe_customer(&self, tenant: &mut Tenant) -> Result<()> {
|
||||
match self.sync_stripe_payment_method(tenant).await {
|
||||
Ok(payment_method_id) => {
|
||||
tenant.stripe_payment_method_id = payment_method_id;
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!(tenant = %tenant.pubkey, error = ?error, "failed to sync payment method");
|
||||
}
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refresh the cached Stripe payment method from Stripe so collection can charge
|
||||
/// it directly and the UI reflects cards added via the portal.
|
||||
async fn sync_stripe_payment_method(&self, tenant: &Tenant) -> Result<Option<String>> {
|
||||
let payment_method_id = self
|
||||
.stripe
|
||||
.get_saved_payment_method(&tenant.stripe_customer_id)
|
||||
.await?;
|
||||
|
||||
if payment_method_id != tenant.stripe_payment_method_id {
|
||||
command::set_tenant_stripe_payment_method(&tenant.pubkey, &payment_method_id).await?;
|
||||
}
|
||||
|
||||
Ok(payment_method_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// One tenant's monthly billing period containing some timestamp, anchored at
|
||||
@@ -517,19 +577,3 @@ impl BillingPeriod {
|
||||
(amount as f64 * self.fraction_remaining(at)).round() as i64
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_error_message(error: &str) -> Option<String> {
|
||||
let normalized = error.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if normalized.chars().count() <= USER_ERROR_MAX_CHARS {
|
||||
return Some(normalized);
|
||||
}
|
||||
|
||||
let prefix_len = USER_ERROR_MAX_CHARS.saturating_sub(3);
|
||||
let mut truncated = normalized.chars().take(prefix_len).collect::<String>();
|
||||
truncated.push_str("...");
|
||||
Some(truncated)
|
||||
}
|
||||
|
||||
@@ -27,8 +27,7 @@ pub async fn get_bitcoin_price(currency: &str) -> Result<f64> {
|
||||
let resp = http.get(url).send().await?;
|
||||
let body: CoinbaseSpotPriceResponse = resp.error_for_status()?.json().await?;
|
||||
|
||||
body
|
||||
.data
|
||||
body.data
|
||||
.amount
|
||||
.parse::<f64>()
|
||||
.map_err(|e| anyhow!("invalid BTC spot quote for {currency}: {e}"))
|
||||
|
||||
+57
-21
@@ -25,8 +25,10 @@ pub async fn create_tenant(tenant: &Tenant) -> Result<()> {
|
||||
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 = ? WHERE pubkey = ?")
|
||||
sqlx::query("UPDATE tenant SET nwc_url = ?, nwc_error = NULL WHERE pubkey = ?")
|
||||
.bind(&tenant.nwc_url)
|
||||
.bind(&tenant.pubkey)
|
||||
.execute(pool())
|
||||
@@ -34,6 +36,24 @@ pub async fn update_tenant(tenant: &Tenant) -> Result<()> {
|
||||
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)
|
||||
@@ -70,9 +90,13 @@ pub async fn churn_tenant(tenant_pubkey: &str, now: i64, relays: &[Relay]) -> Re
|
||||
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?;
|
||||
let activity = set_relay_status_tx(
|
||||
tx,
|
||||
relay,
|
||||
RELAY_STATUS_DELINQUENT,
|
||||
"mark_relay_delinquent",
|
||||
)
|
||||
.await?;
|
||||
activities.push(activity);
|
||||
}
|
||||
}
|
||||
@@ -252,7 +276,10 @@ pub async fn complete_relay_sync(relay: &Relay) -> Result<()> {
|
||||
|
||||
/// 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<()> {
|
||||
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| {
|
||||
@@ -282,12 +309,11 @@ pub async fn insert_invoice_items_for_renewal(
|
||||
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?;
|
||||
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(());
|
||||
@@ -339,7 +365,7 @@ pub async fn create_invoice(tenant: &Tenant, period: &BillingPeriod) -> Result<O
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let invoice = insert_invoice_tx(tx, &tenant, &period, total).await?;
|
||||
let invoice = insert_invoice_tx(tx, tenant, period, total).await?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE invoice_item SET invoice_id = ?
|
||||
@@ -485,14 +511,17 @@ async fn insert_invoice_tx(
|
||||
.bind(invoice_id)
|
||||
.bind(&tenant.pubkey)
|
||||
.bind(amount)
|
||||
.bind(&period.start)
|
||||
.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<()> {
|
||||
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)
|
||||
@@ -520,11 +549,12 @@ async fn mark_activity_billed_tx(
|
||||
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?;
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -578,7 +608,10 @@ async fn mark_invoice_paid_tx(
|
||||
|
||||
/// Void all of a tenant's open invoices, forgiving the balance — used when a
|
||||
/// tenant churns or re-activates, so old debt never has to be collected.
|
||||
async fn void_open_invoices_tx(tx: &mut Transaction<'_, Sqlite>, tenant_pubkey: &str) -> Result<()> {
|
||||
async fn void_open_invoices_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
tenant_pubkey: &str,
|
||||
) -> Result<()> {
|
||||
let voided_at = chrono::Utc::now().timestamp();
|
||||
|
||||
sqlx::query(
|
||||
@@ -615,7 +648,10 @@ async fn clear_tenant_nwc_error_tx(tx: &mut Transaction<'_, Sqlite>, pubkey: &st
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn clear_tenant_stripe_error_tx(tx: &mut Transaction<'_, Sqlite>, pubkey: &str) -> Result<()> {
|
||||
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)
|
||||
|
||||
+10
-2
@@ -74,7 +74,11 @@ async fn reconcile_relay_state(source: &str) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::info!(source, relay_count = relays.len(), "reconciling pending relay state");
|
||||
tracing::info!(
|
||||
source,
|
||||
relay_count = relays.len(),
|
||||
"reconciling pending relay state"
|
||||
);
|
||||
|
||||
for relay in relays {
|
||||
if relay.sync_error.trim().is_empty() {
|
||||
@@ -229,7 +233,11 @@ async fn try_sync_relay(relay: &Relay) -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
let method = if is_new { HttpMethod::POST } else { HttpMethod::PATCH };
|
||||
let method = if is_new {
|
||||
HttpMethod::POST
|
||||
} else {
|
||||
HttpMethod::PATCH
|
||||
};
|
||||
request(method, &format!("relay/{}", relay.id), Some(&body)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,10 +2,10 @@ pub mod api;
|
||||
pub mod billing;
|
||||
pub mod bitcoin;
|
||||
pub mod command;
|
||||
pub mod db;
|
||||
pub mod env;
|
||||
pub mod infra;
|
||||
pub mod models;
|
||||
pub mod db;
|
||||
pub mod query;
|
||||
pub mod robot;
|
||||
pub mod routes;
|
||||
|
||||
+3
-4
@@ -2,10 +2,10 @@ mod api;
|
||||
mod billing;
|
||||
mod bitcoin;
|
||||
mod command;
|
||||
mod db;
|
||||
mod env;
|
||||
mod infra;
|
||||
mod models;
|
||||
mod db;
|
||||
mod query;
|
||||
mod robot;
|
||||
mod routes;
|
||||
@@ -16,7 +16,7 @@ mod web;
|
||||
use anyhow::Result;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer, Any};
|
||||
use tower_http::cors::{AllowOrigin, Any, CorsLayer};
|
||||
|
||||
use crate::api::Api;
|
||||
use crate::billing::Billing;
|
||||
@@ -61,8 +61,7 @@ async fn main() -> Result<()> {
|
||||
billing.start().await;
|
||||
});
|
||||
|
||||
let listener =
|
||||
tokio::net::TcpListener::bind(format!(
|
||||
let listener = tokio::net::TcpListener::bind(format!(
|
||||
"{}:{}",
|
||||
env::get().server_host,
|
||||
env::get().server_port
|
||||
|
||||
+33
-29
@@ -39,7 +39,8 @@ pub struct Tenant {
|
||||
pub nwc_url: String,
|
||||
/// Last NWC auto-payment error, or `None` when the wallet last paid (or has
|
||||
/// never been tried). Surfaced in the UI to warn the user; it never blocks a
|
||||
/// retry — the next reconcile attempts payment again regardless.
|
||||
/// retry — the next reconcile attempts payment again regardless. Also cleared
|
||||
/// when the tenant updates their NWC credentials.
|
||||
pub nwc_error: Option<String>,
|
||||
/// Last Stripe auto-payment error, with the same semantics as `nwc_error`.
|
||||
pub stripe_error: Option<String>,
|
||||
@@ -122,40 +123,43 @@ impl Default for Relay {
|
||||
/// balance forgiven when the tenant churns).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Invoice {
|
||||
pub id: String,
|
||||
pub tenant_pubkey: String,
|
||||
/// The total owed, fixed when the invoice is cut from its outstanding line
|
||||
/// items, so collection never has to re-sum them.
|
||||
pub amount: i64,
|
||||
pub period_start: i64,
|
||||
pub period_end: i64,
|
||||
pub created_at: i64,
|
||||
pub paid_at: Option<i64>,
|
||||
pub voided_at: Option<i64>,
|
||||
pub id: String,
|
||||
pub tenant_pubkey: String,
|
||||
/// The total owed, fixed when the invoice is cut from its outstanding line
|
||||
/// items, so collection never has to re-sum them.
|
||||
pub amount: i64,
|
||||
pub period_start: i64,
|
||||
pub period_end: i64,
|
||||
pub created_at: i64,
|
||||
pub paid_at: Option<i64>,
|
||||
pub voided_at: Option<i64>,
|
||||
/// How the invoice was paid — `nwc`, `stripe`, or `oob` (out-of-band
|
||||
/// Lightning) — set when it is marked paid; `None` while open or void.
|
||||
pub method: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct InvoiceItem {
|
||||
pub id: String,
|
||||
/// `None` while outstanding; set once the item is claimed onto an invoice.
|
||||
pub invoice_id: Option<String>,
|
||||
/// `None` for renewal items, which have no source activity.
|
||||
pub activity_id: Option<String>,
|
||||
pub tenant_pubkey: String,
|
||||
pub relay_id: String,
|
||||
pub plan_id: String,
|
||||
pub amount: i64,
|
||||
pub description: String,
|
||||
pub created_at: i64,
|
||||
pub id: String,
|
||||
/// `None` while outstanding; set once the item is claimed onto an invoice.
|
||||
pub invoice_id: Option<String>,
|
||||
/// `None` for renewal items, which have no source activity.
|
||||
pub activity_id: Option<String>,
|
||||
pub tenant_pubkey: String,
|
||||
pub relay_id: String,
|
||||
pub plan_id: String,
|
||||
pub amount: i64,
|
||||
pub description: String,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Bolt11 {
|
||||
pub id: String,
|
||||
pub invoice_id: String,
|
||||
pub lnbc: String,
|
||||
pub msats: i64,
|
||||
pub created_at: i64,
|
||||
pub expires_at: i64,
|
||||
pub settled_at: Option<i64>,
|
||||
pub id: String,
|
||||
pub invoice_id: String,
|
||||
pub lnbc: String,
|
||||
pub msats: i64,
|
||||
pub created_at: i64,
|
||||
pub expires_at: i64,
|
||||
pub settled_at: Option<i64>,
|
||||
}
|
||||
|
||||
+32
-22
@@ -1,7 +1,7 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
use crate::models::{Activity, Bolt11, Invoice, Plan, Relay, Tenant};
|
||||
use crate::db::pool;
|
||||
use crate::models::{Activity, Bolt11, Invoice, InvoiceItem, Plan, Relay, Tenant};
|
||||
|
||||
fn select_tenant(tail: &str) -> String {
|
||||
format!("SELECT * FROM tenant {tail}")
|
||||
@@ -62,10 +62,12 @@ pub async fn list_tenants() -> Result<Vec<Tenant>> {
|
||||
}
|
||||
|
||||
pub async fn get_tenant(pubkey: &str) -> Result<Option<Tenant>> {
|
||||
Ok(sqlx::query_as::<_, Tenant>(&select_tenant("WHERE pubkey = ?"))
|
||||
.bind(pubkey)
|
||||
.fetch_optional(pool())
|
||||
.await?)
|
||||
Ok(
|
||||
sqlx::query_as::<_, Tenant>(&select_tenant("WHERE pubkey = ?"))
|
||||
.bind(pubkey)
|
||||
.fetch_optional(pool())
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
// --- Relays ---
|
||||
@@ -85,10 +87,12 @@ pub async fn list_relays_pending_sync() -> Result<Vec<Relay>> {
|
||||
}
|
||||
|
||||
pub async fn list_relays_for_tenant(tenant_pubkey: &str) -> Result<Vec<Relay>> {
|
||||
Ok(sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant_pubkey = ?"))
|
||||
.bind(tenant_pubkey)
|
||||
.fetch_all(pool())
|
||||
.await?)
|
||||
Ok(
|
||||
sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant_pubkey = ?"))
|
||||
.bind(tenant_pubkey)
|
||||
.fetch_all(pool())
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn get_relay(id: &str) -> Result<Option<Relay>> {
|
||||
@@ -119,10 +123,12 @@ pub async fn get_relay_plan_before(relay_id: &str, before: i64) -> Result<Option
|
||||
// --- Invoices ---
|
||||
|
||||
pub async fn get_invoice(invoice_id: &str) -> Result<Option<Invoice>> {
|
||||
Ok(sqlx::query_as::<_, Invoice>("SELECT * FROM invoice WHERE id = ?")
|
||||
.bind(invoice_id)
|
||||
.fetch_optional(pool())
|
||||
.await?)
|
||||
Ok(
|
||||
sqlx::query_as::<_, Invoice>("SELECT * FROM invoice WHERE id = ?")
|
||||
.bind(invoice_id)
|
||||
.fetch_optional(pool())
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn list_invoices(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
|
||||
@@ -134,12 +140,14 @@ pub async fn list_invoices(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_latest_invoice(tenant_pubkey: &str) -> Result<Option<Invoice>> {
|
||||
Ok(sqlx::query_as::<_, Invoice>(
|
||||
"SELECT * FROM invoice WHERE tenant_pubkey = ? ORDER BY created_at DESC LIMIT 1",
|
||||
/// The line items claimed onto an invoice, oldest first. Used to render an
|
||||
/// invoice's contents (and its downloadable copy) from what was actually billed.
|
||||
pub async fn list_invoice_items_for_invoice(invoice_id: &str) -> Result<Vec<InvoiceItem>> {
|
||||
Ok(sqlx::query_as::<_, InvoiceItem>(
|
||||
"SELECT * FROM invoice_item WHERE invoice_id = ? ORDER BY created_at ASC",
|
||||
)
|
||||
.bind(tenant_pubkey)
|
||||
.fetch_optional(pool())
|
||||
.bind(invoice_id)
|
||||
.fetch_all(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
@@ -159,10 +167,12 @@ pub async fn list_open_invoices(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
|
||||
// --- Bolt11 ---
|
||||
|
||||
pub async fn get_bolt11(bolt11_id: &str) -> Result<Option<Bolt11>> {
|
||||
Ok(sqlx::query_as::<_, Bolt11>("SELECT * FROM bolt11 WHERE id = ?")
|
||||
.bind(bolt11_id)
|
||||
.fetch_optional(pool())
|
||||
.await?)
|
||||
Ok(
|
||||
sqlx::query_as::<_, Bolt11>("SELECT * FROM bolt11 WHERE id = ?")
|
||||
.bind(bolt11_id)
|
||||
.fetch_optional(pool())
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn get_bolt11_for_invoice(invoice_id: &str) -> Result<Option<Bolt11>> {
|
||||
|
||||
+16
-9
@@ -43,10 +43,7 @@ impl Robot {
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
|
||||
async fn publish_identity(
|
||||
&self,
|
||||
) -> Result<()> {
|
||||
async fn publish_identity(&self) -> Result<()> {
|
||||
let mut metadata = Metadata::new();
|
||||
if !env::get().robot_name.is_empty() {
|
||||
metadata = metadata.name(&env::get().robot_name);
|
||||
@@ -65,7 +62,8 @@ impl Robot {
|
||||
.send_event_builder(EventBuilder::metadata(&metadata))
|
||||
.await?;
|
||||
|
||||
let outbox_tags = env::get().robot_outbox_relays
|
||||
let outbox_tags = env::get()
|
||||
.robot_outbox_relays
|
||||
.iter()
|
||||
.map(|r| Tag::parse(["r", r.as_str()]))
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
@@ -73,7 +71,8 @@ impl Robot {
|
||||
.send_event_builder(EventBuilder::new(Kind::Custom(10002), "").tags(outbox_tags))
|
||||
.await?;
|
||||
|
||||
let messaging_tags = env::get().robot_messaging_relays
|
||||
let messaging_tags = env::get()
|
||||
.robot_messaging_relays
|
||||
.iter()
|
||||
.map(|r| Tag::parse(["relay", r.as_str()]))
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
@@ -100,7 +99,9 @@ impl Robot {
|
||||
|
||||
let recipient_pubkey = PublicKey::parse(recipient)?;
|
||||
let client = self.make_client(&dm_relays).await?;
|
||||
client.send_private_msg(recipient_pubkey, message, []).await?;
|
||||
client
|
||||
.send_private_msg(recipient_pubkey, message, [])
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -132,8 +133,14 @@ impl Robot {
|
||||
pub async fn fetch_nostr_name(&self, pubkey: &str) -> Option<String> {
|
||||
let pubkey = PublicKey::parse(pubkey).ok()?;
|
||||
let filter = Filter::new().author(pubkey).kind(Kind::Metadata).limit(1);
|
||||
let client = self.make_client(&env::get().robot_indexer_relays).await.ok()?;
|
||||
let events = client.fetch_events(filter, Duration::from_secs(5)).await.ok()?;
|
||||
let client = self
|
||||
.make_client(&env::get().robot_indexer_relays)
|
||||
.await
|
||||
.ok()?;
|
||||
let events = client
|
||||
.fetch_events(filter, Duration::from_secs(5))
|
||||
.await
|
||||
.ok()?;
|
||||
let event = events.into_iter().max_by_key(|e| e.created_at)?;
|
||||
let content: serde_json::Value = serde_json::from_str(&event.content).ok()?;
|
||||
let name = content
|
||||
|
||||
@@ -6,26 +6,6 @@ use crate::api::{Api, AuthedPubkey};
|
||||
use crate::query;
|
||||
use crate::web::{ApiResult, internal, not_found, ok};
|
||||
|
||||
/// The tenant's most recent invoice, after first materializing any outstanding
|
||||
/// line items into a fresh one — so the frontend can collect payment right after
|
||||
/// a change (e.g. creating a relay). Payment isn't attempted here; the caller
|
||||
/// drives it via the bolt11/Stripe endpoints. `null` when the tenant has no
|
||||
/// invoices and nothing is outstanding.
|
||||
pub async fn get_tenant_latest_invoice(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
let tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||
|
||||
api.billing.reconcile_subscription(&tenant).await.map_err(internal)?;
|
||||
|
||||
let invoice = query::get_latest_invoice(&pubkey).await.map_err(internal)?;
|
||||
|
||||
ok(invoice)
|
||||
}
|
||||
|
||||
pub async fn get_invoice(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
@@ -63,3 +43,24 @@ pub async fn get_invoice_bolt11(
|
||||
|
||||
ok(serde_json::json!(bolt11))
|
||||
}
|
||||
|
||||
/// The line items billed on an invoice, for rendering its contents and a
|
||||
/// downloadable copy.
|
||||
pub async fn list_invoice_items(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(invoice_id): Path<String>,
|
||||
) -> ApiResult {
|
||||
let invoice = query::get_invoice(&invoice_id)
|
||||
.await
|
||||
.map_err(internal)?
|
||||
.ok_or_else(|| not_found("invoice not found"))?;
|
||||
|
||||
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
|
||||
|
||||
let items = query::list_invoice_items_for_invoice(&invoice_id)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
ok(items)
|
||||
}
|
||||
|
||||
@@ -9,14 +9,12 @@ use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::api::{Api, AuthedPubkey};
|
||||
use crate::{command, infra, query};
|
||||
use crate::models::{
|
||||
RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay,
|
||||
};
|
||||
use crate::models::{RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
|
||||
use crate::web::{
|
||||
ApiError, ApiResult, bad_request, created, internal, map_unique_error, ok,
|
||||
parse_bool_default, unprocessable,
|
||||
ApiError, ApiResult, bad_request, created, internal, map_unique_error, ok, parse_bool_default,
|
||||
unprocessable,
|
||||
};
|
||||
use crate::{command, infra, query};
|
||||
|
||||
pub async fn list_relays(
|
||||
State(api): State<Arc<Api>>,
|
||||
@@ -196,10 +194,7 @@ pub async fn update_relay(
|
||||
if plan_changed {
|
||||
let selected_plan = query::get_plan(&relay.plan_id).map_err(internal)?;
|
||||
if let Some(limit) = selected_plan.members {
|
||||
let current_members = fetch_relay_members(&relay)
|
||||
.await
|
||||
.map_err(internal)?
|
||||
.len() as i64;
|
||||
let current_members = fetch_relay_members(&relay).await.map_err(internal)?.len() as i64;
|
||||
|
||||
if current_members > limit {
|
||||
let message = format!(
|
||||
@@ -231,12 +226,13 @@ pub async fn deactivate_relay(
|
||||
}
|
||||
|
||||
if relay.status == RELAY_STATUS_INACTIVE {
|
||||
return Err(bad_request("relay-is-inactive", "relay is already inactive"));
|
||||
return Err(bad_request(
|
||||
"relay-is-inactive",
|
||||
"relay is already inactive",
|
||||
));
|
||||
}
|
||||
|
||||
command::deactivate_relay(&relay)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
command::deactivate_relay(&relay).await.map_err(internal)?;
|
||||
|
||||
ok(())
|
||||
}
|
||||
@@ -282,15 +278,21 @@ static SUBDOMAIN_RE: LazyLock<Regex> =
|
||||
/// premium features, and coerce the boolean columns to 0/1.
|
||||
fn prepare_relay(mut relay: Relay) -> Result<Relay, ApiError> {
|
||||
if !SUBDOMAIN_RE.is_match(&relay.subdomain)
|
||||
|| RESERVED_SUBDOMAINS.contains(&relay.subdomain.as_str()) {
|
||||
|| RESERVED_SUBDOMAINS.contains(&relay.subdomain.as_str())
|
||||
{
|
||||
return Err(unprocessable("invalid-subdomain", "subdomain is invalid"));
|
||||
}
|
||||
|
||||
let plan = query::get_plan(&relay.plan_id)
|
||||
.map_err(|_| unprocessable("invalid-plan", "plan not found"))?;
|
||||
|
||||
if (!plan.blossom && relay.blossom_enabled == 1) || (!plan.livekit && relay.livekit_enabled == 1) {
|
||||
return Err(unprocessable("premium-feature", "feature requires a paid plan"));
|
||||
if (!plan.blossom && relay.blossom_enabled == 1)
|
||||
|| (!plan.livekit && relay.livekit_enabled == 1)
|
||||
{
|
||||
return Err(unprocessable(
|
||||
"premium-feature",
|
||||
"feature requires a paid plan",
|
||||
));
|
||||
}
|
||||
|
||||
relay.policy_public_join = parse_bool_default(relay.policy_public_join, 0);
|
||||
|
||||
@@ -56,6 +56,21 @@ pub async fn list_tenants(
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// Fetch a tenant by pubkey. Automatically refreshes the tenant's stripe payment data.
|
||||
pub async fn get_tenant(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
|
||||
let mut tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||
|
||||
api.billing.sync_stripe_customer(&mut tenant).await.map_err(internal)?;
|
||||
|
||||
ok(TenantResponse::from(tenant))
|
||||
}
|
||||
|
||||
/// Create the tenant row for the calling pubkey and provision its Stripe
|
||||
/// customer. Idempotent: an existing tenant (including one created by a
|
||||
/// concurrent unique-constraint race) is returned as-is.
|
||||
@@ -99,16 +114,6 @@ pub async fn create_tenant(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_tenant(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
Path(pubkey): Path<String>,
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
let tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||
ok(TenantResponse::from(tenant))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateTenantRequest {
|
||||
pub nwc_url: Option<String>,
|
||||
@@ -136,6 +141,7 @@ pub async fn update_tenant(
|
||||
ok(TenantResponse::from(tenant))
|
||||
}
|
||||
|
||||
/// List a tenant's relays.
|
||||
pub async fn list_tenant_relays(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
@@ -149,7 +155,7 @@ pub async fn list_tenant_relays(
|
||||
ok(relays)
|
||||
}
|
||||
|
||||
/// List a tenant's invoices, most recent first.
|
||||
/// List a tenant's invoices after reconciling the tenant's billing state.
|
||||
pub async fn list_tenant_invoices(
|
||||
State(api): State<Arc<Api>>,
|
||||
AuthedPubkey(auth): AuthedPubkey,
|
||||
@@ -157,10 +163,15 @@ pub async fn list_tenant_invoices(
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
|
||||
let invoices = query::list_invoices(&pubkey)
|
||||
let tenant = api.get_tenant_or_404(&pubkey).await?;
|
||||
|
||||
api.billing
|
||||
.reconcile_subscription(&tenant, false)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
let invoices = query::list_invoices(&pubkey).await.map_err(internal)?;
|
||||
|
||||
ok(invoices)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ pub struct Wallet {
|
||||
|
||||
impl Wallet {
|
||||
pub fn from_url(url: &str) -> Result<Self> {
|
||||
Ok(Self { url: url.parse::<NostrWalletConnectURI>()? })
|
||||
Ok(Self {
|
||||
url: url.parse::<NostrWalletConnectURI>()?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn make_invoice(
|
||||
|
||||
+5
-1
@@ -71,7 +71,11 @@ pub fn err(status: StatusCode, code: &str, message: &str) -> ApiError {
|
||||
}
|
||||
|
||||
pub fn unauthorized(reason: impl Display) -> ApiError {
|
||||
err(StatusCode::UNAUTHORIZED, "unauthorized", &reason.to_string())
|
||||
err(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"unauthorized",
|
||||
&reason.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn forbidden(message: &str) -> ApiError {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { A, useLocation } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
|
||||
import Fuse from "fuse.js"
|
||||
import { primeProfiles, useProfilePicture, useTenant, useTenantRelays, type Relay } from "@/lib/hooks"
|
||||
import { invoiceStatus, listTenantInvoices, type Invoice } from "@/lib/api"
|
||||
import { primeProfiles, useProfilePicture, useTenantRelays, type Relay } from "@/lib/hooks"
|
||||
import { account, eventStore, identity } from "@/lib/state"
|
||||
import serverIcon from "@/assets/server.svg"
|
||||
import Modal from "@/components/Modal"
|
||||
import PaymentDialog from "@/components/PaymentDialog"
|
||||
import BillingPrompts from "@/components/BillingPrompts"
|
||||
|
||||
type Profile = {
|
||||
name?: string
|
||||
@@ -35,28 +34,10 @@ function RelayIcon() {
|
||||
export default function AppShell(props: { children?: any }) {
|
||||
const location = useLocation()
|
||||
const picture = useProfilePicture(() => account()?.pubkey)
|
||||
const [tenant] = useTenant()
|
||||
const [tenantRelays] = useTenantRelays()
|
||||
const [profile, setProfile] = createSignal<Profile>({})
|
||||
const [searchOpen, setSearchOpen] = createSignal(false)
|
||||
const [searchQuery, setSearchQuery] = createSignal("")
|
||||
const [pastDueInvoice, setPastDueInvoice] = createSignal<Invoice | undefined>()
|
||||
const [showPaymentDialog, setShowPaymentDialog] = createSignal(false)
|
||||
|
||||
createEffect(async () => {
|
||||
const t = tenant()
|
||||
if (!t?.churned_at) {
|
||||
setPastDueInvoice(undefined)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const invoices = await listTenantInvoices(t.pubkey)
|
||||
const openInvoice = invoices.find(inv => invoiceStatus(inv) === "open")
|
||||
setPastDueInvoice(openInvoice)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
|
||||
const username = createMemo(() => profile().name || profile().display_name || shortenPubkey(account()?.pubkey))
|
||||
const nip05 = createMemo(() => profile().nip05 || "No NIP-05")
|
||||
@@ -158,36 +139,14 @@ export default function AppShell(props: { children?: any }) {
|
||||
</aside>
|
||||
|
||||
<div class="md:pl-[260px] min-h-screen pb-20 md:pb-0">
|
||||
<Show when={tenant()?.churned_at}>
|
||||
<div class="bg-red-600 text-white px-4 py-3 text-sm flex items-center justify-between">
|
||||
<span>Your account is past due and some relays are paused. Update your payment method to restore service.</span>
|
||||
<Show when={pastDueInvoice()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPaymentDialog(true)}
|
||||
class="font-medium underline hover:no-underline"
|
||||
>
|
||||
Pay now
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
{/* Shared billing prompts on every dashboard page; the billing page
|
||||
renders its own contextual (inline) variant instead. */}
|
||||
<Show when={location.pathname !== "/account"}>
|
||||
<BillingPrompts variant="banner" />
|
||||
</Show>
|
||||
<main>{props.children}</main>
|
||||
</div>
|
||||
|
||||
<Show when={pastDueInvoice() && showPaymentDialog()}>
|
||||
{(_) => {
|
||||
const invoice = pastDueInvoice()!
|
||||
return (
|
||||
<PaymentDialog
|
||||
invoice={invoice}
|
||||
open={true}
|
||||
onClose={() => setShowPaymentDialog(false)}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<nav
|
||||
class="fixed inset-x-0 bottom-0 z-20 border-t border-gray-200 bg-white md:hidden"
|
||||
onClick={() => {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { createMemo, createResource, createSignal, Show } from "solid-js"
|
||||
import { useSearchParams } from "@solidjs/router"
|
||||
import PromptBanner, { type PromptBannerAction } from "@/components/PromptBanner"
|
||||
import PaymentDialog from "@/components/PaymentDialog"
|
||||
import PaymentSetup from "@/components/PaymentSetup"
|
||||
import { getInvoice, type Invoice } from "@/lib/api"
|
||||
import { activeBillingPrompt, billingFlowActive, useBillingStatus, type BillingPromptKind } from "@/lib/billing"
|
||||
|
||||
type BillingPromptsProps = {
|
||||
// "banner" sits in the dashboard shell (mounted on every page except the
|
||||
// billing page); "inline" is shown contextually on the billing page itself.
|
||||
// Only one is ever mounted at a time, so each can own its own modals + deep link.
|
||||
variant?: "banner" | "inline"
|
||||
}
|
||||
|
||||
export default function BillingPrompts(props: BillingPromptsProps) {
|
||||
const status = useBillingStatus()
|
||||
const [dismissed, setDismissed] = createSignal<Set<BillingPromptKind>>(new Set())
|
||||
const [payInvoice, setPayInvoice] = createSignal<Invoice | undefined>()
|
||||
const [setupOpen, setSetupOpen] = createSignal(false)
|
||||
const [setupTab, setSetupTab] = createSignal<"nwc" | "card">("nwc")
|
||||
|
||||
// Deep link: /...?invoice=<id> (e.g. from the billing DM) opens the payment
|
||||
// dialog on whatever dashboard page the link lands on.
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [deepLinked] = createResource(
|
||||
() => searchParams.invoice as string | undefined,
|
||||
(id) => getInvoice(id),
|
||||
)
|
||||
|
||||
const prompt = createMemo(() =>
|
||||
activeBillingPrompt(
|
||||
{
|
||||
tenant: status.tenant(),
|
||||
openInvoice: status.openInvoice(),
|
||||
hasPaidSubscription: status.hasPaidSubscription(),
|
||||
},
|
||||
{ suppressInline: billingFlowActive() },
|
||||
),
|
||||
)
|
||||
|
||||
const visiblePrompt = createMemo(() => {
|
||||
const p = prompt()
|
||||
if (!p || dismissed().has(p.kind)) return undefined
|
||||
return p
|
||||
})
|
||||
|
||||
function openSetup(tab: "nwc" | "card") {
|
||||
setSetupTab(tab)
|
||||
setSetupOpen(true)
|
||||
}
|
||||
|
||||
const actions = createMemo<PromptBannerAction[]>(() => {
|
||||
const p = visiblePrompt()
|
||||
if (!p) return []
|
||||
const open = status.openInvoice()
|
||||
switch (p.kind) {
|
||||
case "churned":
|
||||
return open
|
||||
? [
|
||||
{ label: "Pay now", onClick: () => setPayInvoice(open) },
|
||||
{ label: "Update payment method", onClick: () => openSetup("nwc") },
|
||||
]
|
||||
: [{ label: "Update payment method", onClick: () => openSetup("nwc") }]
|
||||
case "pay_invoice":
|
||||
return open ? [{ label: "Pay now", onClick: () => setPayInvoice(open) }] : []
|
||||
case "update_method":
|
||||
return [{ label: "Update payment method", onClick: () => openSetup(status.tenant()?.nwc_error ? "nwc" : "card") }]
|
||||
case "setup_autopay":
|
||||
return [{ label: "Set up autopay", onClick: () => openSetup("nwc") }]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const dismissible = () => visiblePrompt()?.kind === "setup_autopay"
|
||||
|
||||
function dismiss() {
|
||||
const p = visiblePrompt()
|
||||
if (p) setDismissed((prev) => new Set(prev).add(p.kind))
|
||||
}
|
||||
|
||||
function clearDeepLink() {
|
||||
if (searchParams.invoice) setSearchParams({ invoice: undefined })
|
||||
}
|
||||
|
||||
const outerClass = () => (props.variant === "inline" ? "" : "mx-4 mt-4 md:mx-6")
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={visiblePrompt()}>
|
||||
{(p) => (
|
||||
<PromptBanner
|
||||
severity={p().severity}
|
||||
message={p().message}
|
||||
actions={actions()}
|
||||
onDismiss={dismissible() ? dismiss : undefined}
|
||||
class={outerClass()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
{/* Pay an invoice — from a prompt action or a deep link. */}
|
||||
<Show when={payInvoice() ?? deepLinked()}>
|
||||
{(invoice) => (
|
||||
<PaymentDialog
|
||||
invoice={invoice()}
|
||||
open={true}
|
||||
onClose={() => {
|
||||
const wasDeepLink = !payInvoice()
|
||||
setPayInvoice(undefined)
|
||||
if (wasDeepLink) clearDeepLink()
|
||||
status.refetch()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<PaymentSetup
|
||||
open={setupOpen()}
|
||||
initialTab={setupTab()}
|
||||
onClose={() => {
|
||||
setSetupOpen(false)
|
||||
status.refetch()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { createEffect, createResource, createSignal, For, Show } from "solid-js"
|
||||
import QRCode from "qrcode"
|
||||
import Modal from "@/components/Modal"
|
||||
import PaymentSetup from "@/components/PaymentSetup"
|
||||
import { getInvoice, getInvoiceBolt11, type Invoice } from "@/lib/api"
|
||||
import { useTenantRelays } from "@/lib/hooks"
|
||||
import { plans } from "@/lib/state"
|
||||
import { getInvoice, getInvoiceBolt11, listInvoiceItems, type Invoice } from "@/lib/api"
|
||||
import { billingTenant } from "@/lib/state"
|
||||
|
||||
type PayStatus = "idle" | "loading" | "success" | "error"
|
||||
type Bolt11Status = "idle" | "loading" | "ready" | "error"
|
||||
@@ -27,14 +26,15 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
const [payError, setPayError] = createSignal("")
|
||||
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
|
||||
const [setupSaved, setSetupSaved] = createSignal(false)
|
||||
const [relays] = useTenantRelays()
|
||||
const [items] = createResource(
|
||||
() => (props.open ? props.invoice.id : undefined),
|
||||
listInvoiceItems,
|
||||
)
|
||||
|
||||
const billedRelays = createMemo(() => {
|
||||
const planById = new Map(plans().map((p) => [p.id, p]))
|
||||
return (relays() ?? [])
|
||||
.map((relay) => ({ relay, plan: planById.get(relay.plan_id) }))
|
||||
.filter((entry) => Boolean(entry.plan?.amount))
|
||||
})
|
||||
const autopayConfigured = () => {
|
||||
const t = billingTenant()
|
||||
return Boolean(t?.nwc_is_set || t?.stripe_payment_method_id)
|
||||
}
|
||||
|
||||
async function loadBolt11() {
|
||||
if (!props.invoice.id) return
|
||||
@@ -44,9 +44,9 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
setQrDataUrl("")
|
||||
|
||||
try {
|
||||
const { bolt11: invoice } = await getInvoiceBolt11(props.invoice.id)
|
||||
setBolt11(invoice)
|
||||
setQrDataUrl(await QRCode.toDataURL(invoice, { width: 256, margin: 2 }))
|
||||
const { lnbc } = await getInvoiceBolt11(props.invoice.id)
|
||||
setBolt11(lnbc)
|
||||
setQrDataUrl(await QRCode.toDataURL(lnbc, { width: 256, margin: 2 }))
|
||||
setBolt11Status("ready")
|
||||
} catch (e) {
|
||||
setBolt11Status("error")
|
||||
@@ -137,19 +137,16 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
when={payStatus() === "success"}
|
||||
fallback={
|
||||
<div class="w-full space-y-4">
|
||||
{/* What's being paid for */}
|
||||
<Show when={billedRelays().length > 0}>
|
||||
{/* What's being paid for — the invoice's actual line items */}
|
||||
<Show when={(items() ?? []).length > 0}>
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Relays on this invoice</p>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">On this invoice</p>
|
||||
<ul class="space-y-1.5">
|
||||
<For each={billedRelays()}>
|
||||
{({ relay, plan }) => (
|
||||
<For each={items()}>
|
||||
{(item) => (
|
||||
<li class="flex items-center justify-between gap-3 text-sm">
|
||||
<span class="truncate text-gray-900">{relay.info_name || relay.subdomain}</span>
|
||||
<span class="flex-shrink-0 text-xs text-gray-500">
|
||||
{plan?.name ?? relay.plan_id}
|
||||
<Show when={plan}> · ${(plan!.amount / 100).toFixed(2)}/mo</Show>
|
||||
</span>
|
||||
<span class="truncate text-gray-900">{item.description}</span>
|
||||
<span class="flex-shrink-0 text-xs text-gray-500">${(item.amount / 100).toFixed(2)}</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
@@ -221,13 +218,15 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900">Payment confirmed!</p>
|
||||
<p class="text-xs text-gray-500">Thank you. Your account is up to date.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPaymentSetup(true)}
|
||||
class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Set up automatic payments
|
||||
</button>
|
||||
<Show when={!autopayConfigured()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPaymentSetup(true)}
|
||||
class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Set up automatic payments
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { createEffect, createSignal, Show } from "solid-js"
|
||||
import Modal from "@/components/Modal"
|
||||
import { updateActiveTenant } from "@/lib/hooks"
|
||||
import { createPortalSession } from "@/lib/api"
|
||||
@@ -10,10 +10,18 @@ type PaymentSetupProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSaved?: () => void
|
||||
// Which method to show first. Lightning/NWC is the default; pass "card" to land
|
||||
// a tenant on the card tab (e.g. when their card is the method that failed).
|
||||
initialTab?: Tab
|
||||
}
|
||||
|
||||
export default function PaymentSetup(props: PaymentSetupProps) {
|
||||
const [tab, setTab] = createSignal<Tab>("nwc")
|
||||
const [tab, setTab] = createSignal<Tab>(props.initialTab ?? "nwc")
|
||||
|
||||
// Reset to the requested tab each time the dialog opens.
|
||||
createEffect(() => {
|
||||
if (props.open) setTab(props.initialTab ?? "nwc")
|
||||
})
|
||||
const [nwcUrl, setNwcUrl] = createSignal("")
|
||||
const [saving, setSaving] = createSignal(false)
|
||||
const [saved, setSaved] = createSignal(false)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { For, Show } from "solid-js"
|
||||
|
||||
export type PromptBannerAction = {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
type Severity = "error" | "warn" | "info"
|
||||
|
||||
type PromptBannerProps = {
|
||||
severity: Severity
|
||||
message: string
|
||||
actions?: PromptBannerAction[]
|
||||
onDismiss?: () => void
|
||||
class?: string
|
||||
}
|
||||
|
||||
const containerStyles: Record<Severity, string> = {
|
||||
error: "border-red-200 bg-red-50 text-red-800",
|
||||
warn: "border-amber-200 bg-amber-50 text-amber-800",
|
||||
info: "border-blue-200 bg-blue-50 text-blue-800",
|
||||
}
|
||||
|
||||
const actionStyles: Record<Severity, string> = {
|
||||
error: "text-red-800 hover:text-red-900",
|
||||
warn: "text-amber-800 hover:text-amber-900",
|
||||
info: "text-blue-800 hover:text-blue-900",
|
||||
}
|
||||
|
||||
export default function PromptBanner(props: PromptBannerProps) {
|
||||
return (
|
||||
<div class={`rounded-lg border p-4 flex items-start justify-between gap-4 ${containerStyles[props.severity]} ${props.class ?? ""}`.trim()}>
|
||||
<p class="text-sm min-w-0">{props.message}</p>
|
||||
<div class="flex items-center gap-3 shrink-0">
|
||||
<For each={props.actions ?? []}>
|
||||
{(action) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
class={`text-sm font-medium underline hover:no-underline whitespace-nowrap ${actionStyles[props.severity]}`}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<Show when={props.onDismiss}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onDismiss?.()}
|
||||
aria-label="Dismiss"
|
||||
class={`shrink-0 opacity-70 hover:opacity-100 ${actionStyles[props.severity]}`}
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+29
-1
@@ -97,6 +97,7 @@ export type Tenant = {
|
||||
pubkey: string
|
||||
nwc_is_set: boolean
|
||||
created_at: number
|
||||
billing_anchor: number | null
|
||||
stripe_customer_id: string
|
||||
stripe_payment_method_id: string | null
|
||||
nwc_error: string | null
|
||||
@@ -113,6 +114,29 @@ export type Invoice = {
|
||||
created_at: number
|
||||
paid_at: number | null
|
||||
voided_at: number | null
|
||||
method: "nwc" | "stripe" | "oob" | null
|
||||
}
|
||||
|
||||
export type InvoiceItem = {
|
||||
id: string
|
||||
invoice_id: string | null
|
||||
activity_id: string | null
|
||||
tenant_pubkey: string
|
||||
relay_id: string
|
||||
plan_id: string
|
||||
amount: number
|
||||
description: string
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export type Bolt11 = {
|
||||
id: string
|
||||
invoice_id: string
|
||||
lnbc: string
|
||||
msats: number
|
||||
created_at: number
|
||||
expires_at: number
|
||||
settled_at: number | null
|
||||
}
|
||||
|
||||
// The backend models an invoice's lifecycle as timestamps rather than a status
|
||||
@@ -265,7 +289,11 @@ export function createPortalSession(pubkey: string, returnUrl?: string) {
|
||||
}
|
||||
|
||||
export function getInvoiceBolt11(invoiceId: string) {
|
||||
return callApi<undefined, { bolt11: string }>("GET", `/invoices/${invoiceId}/bolt11`)
|
||||
return callApi<undefined, Bolt11>("GET", `/invoices/${invoiceId}/bolt11`)
|
||||
}
|
||||
|
||||
export function listInvoiceItems(invoiceId: string) {
|
||||
return callApi<undefined, InvoiceItem[]>("GET", `/invoices/${invoiceId}/items`)
|
||||
}
|
||||
|
||||
export function createRelay(input: CreateRelayInput) {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { invoiceStatus, type Invoice, type Tenant } from "@/lib/api"
|
||||
import { billingInvoices, billingRelays, billingTenant, plans, refetchBilling } from "@/lib/state"
|
||||
|
||||
// Set while the create/upgrade flow drives its own payment/setup modals, so the
|
||||
// shared prompt surface doesn't double-prompt for the same invoice. Cleared on
|
||||
// every close path of that flow.
|
||||
export const [billingFlowActive, setBillingFlowActive] = createSignal(false)
|
||||
|
||||
export type BillingPromptKind = "churned" | "pay_invoice" | "update_method" | "setup_autopay"
|
||||
|
||||
export type BillingPrompt = {
|
||||
kind: BillingPromptKind
|
||||
severity: "error" | "warn" | "info"
|
||||
message: string
|
||||
}
|
||||
|
||||
export type BillingStatusSnapshot = {
|
||||
tenant: Tenant | undefined
|
||||
openInvoice: Invoice | undefined
|
||||
hasPaidSubscription: boolean
|
||||
}
|
||||
|
||||
// The single billing read shared by the dashboard shell and the billing page.
|
||||
// `openInvoice` is the OLDEST open, positive invoice — matching the backend's
|
||||
// dunning order so the UI pays the same one collection targets.
|
||||
export function useBillingStatus() {
|
||||
const tenant = () => billingTenant()
|
||||
const invoices = () => billingInvoices() ?? []
|
||||
|
||||
const openInvoices = createMemo(() =>
|
||||
invoices()
|
||||
.filter((inv) => invoiceStatus(inv) === "open" && inv.amount > 0)
|
||||
.sort((a, b) => a.created_at - b.created_at),
|
||||
)
|
||||
const openInvoice = () => openInvoices()[0]
|
||||
|
||||
// Amount due: the total of all open invoices.
|
||||
const balance = () => openInvoices().reduce((sum, inv) => sum + inv.amount, 0)
|
||||
|
||||
const hasPaidSubscription = createMemo(() => {
|
||||
const planById = new Map(plans().map((p) => [p.id, p]))
|
||||
return (billingRelays() ?? []).some((relay) => {
|
||||
const plan = planById.get(relay.plan_id)
|
||||
return Boolean(plan && plan.amount > 0 && relay.status === "active")
|
||||
})
|
||||
})
|
||||
|
||||
const loading = () => billingTenant.loading || billingInvoices.loading
|
||||
|
||||
return { tenant, invoices, balance, openInvoice, hasPaidSubscription, loading, refetch: refetchBilling }
|
||||
}
|
||||
|
||||
// Pure priority selector: returns the single highest-priority billing prompt to
|
||||
// surface, or null. Priority: churned > pay an open invoice > fix a failed method
|
||||
// > set up autopay. `suppressInline` hides the prompts the create/upgrade inline
|
||||
// flow already handles (pay_invoice, setup_autopay) while still surfacing churn
|
||||
// and method errors.
|
||||
export function activeBillingPrompt(
|
||||
s: BillingStatusSnapshot,
|
||||
opts?: { suppressInline?: boolean },
|
||||
): BillingPrompt | null {
|
||||
const tenant = s.tenant
|
||||
if (!tenant) return null
|
||||
|
||||
if (tenant.churned_at) {
|
||||
return {
|
||||
kind: "churned",
|
||||
severity: "error",
|
||||
message:
|
||||
"Your account is past due and some relays are paused. Pay your balance or update your payment method to restore service.",
|
||||
}
|
||||
}
|
||||
|
||||
const autopayConfigured = tenant.nwc_is_set || Boolean(tenant.stripe_payment_method_id)
|
||||
const methodError = tenant.nwc_error ?? tenant.stripe_error
|
||||
const suppressInline = opts?.suppressInline ?? false
|
||||
|
||||
if (s.openInvoice && !suppressInline && (!autopayConfigured || methodError)) {
|
||||
return {
|
||||
kind: "pay_invoice",
|
||||
severity: "warn",
|
||||
message: "You have an unpaid invoice. Pay it now to keep your relays running.",
|
||||
}
|
||||
}
|
||||
|
||||
if (methodError) {
|
||||
return {
|
||||
kind: "update_method",
|
||||
severity: "warn",
|
||||
message: tenant.nwc_error
|
||||
? "Your Lightning wallet couldn't be charged. Update your payment method."
|
||||
: "Your card couldn't be charged. Update your payment method.",
|
||||
}
|
||||
}
|
||||
|
||||
if (s.hasPaidSubscription && !autopayConfigured && !s.openInvoice && !suppressInline) {
|
||||
return {
|
||||
kind: "setup_autopay",
|
||||
severity: "info",
|
||||
message: "Set up automatic payments so your subscription renews without interruption.",
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { EventStore } from "applesauce-core"
|
||||
import { createEventLoaderForStore } from "applesauce-loaders/loaders"
|
||||
import { RelayPool } from "applesauce-relay"
|
||||
import { NostrConnectSigner } from "applesauce-signers"
|
||||
import { getIdentity, listPlans, registerAccountGetter, type Plan } from "@/lib/api"
|
||||
import { getIdentity, getTenant, listPlans, listTenantInvoices, listTenantRelays, registerAccountGetter, type Plan } from "@/lib/api"
|
||||
|
||||
export type UnsignedEvent = {
|
||||
kind: number
|
||||
@@ -55,6 +55,22 @@ export const [identity, { refetch: refetchIdentity, mutate: setIdentity }] = cre
|
||||
}
|
||||
)
|
||||
|
||||
// Shared billing reads, fetched once per session and consumed by the dashboard
|
||||
// shell, the billing page, and the billing-prompt surface. Keyed on the active
|
||||
// pubkey so they refetch on account switch; refetchBilling() refreshes them all
|
||||
// after a mutation (payment, method update, plan change).
|
||||
const billingKey = () => account()?.pubkey
|
||||
|
||||
export const [billingTenant, { refetch: refetchBillingTenant }] = createResource(billingKey, getTenant)
|
||||
export const [billingInvoices, { refetch: refetchBillingInvoices }] = createResource(billingKey, listTenantInvoices)
|
||||
export const [billingRelays, { refetch: refetchBillingRelays }] = createResource(billingKey, listTenantRelays)
|
||||
|
||||
export function refetchBilling() {
|
||||
void refetchBillingTenant()
|
||||
void refetchBillingInvoices()
|
||||
void refetchBillingRelays()
|
||||
}
|
||||
|
||||
// Deferred to avoid circular-import TDZ errors (api.ts <-> state.ts)
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import QRCode from "qrcode"
|
||||
import { getInvoiceBolt11, invoiceStatus, listInvoiceItems, type Invoice, type InvoiceItem } from "@/lib/api"
|
||||
import { PLATFORM_NAME } from "@/lib/state"
|
||||
|
||||
const methodLabels: Record<string, string> = {
|
||||
nwc: "Lightning",
|
||||
stripe: "Card",
|
||||
oob: "Lightning (out of band)",
|
||||
}
|
||||
|
||||
const fmtUsd = (cents: number) => `$${(cents / 100).toFixed(2)}`
|
||||
const fmtDate = (seconds: number) => new Date(seconds * 1000).toLocaleDateString()
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
const map: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }
|
||||
return value.replace(/[&<>"']/g, (c) => map[c])
|
||||
}
|
||||
|
||||
// Generates a printable invoice and opens the browser's print/save-as-PDF dialog.
|
||||
// No PDF dependency: the invoice is rendered as standalone HTML into an off-screen
|
||||
// iframe so the current page is never disturbed. The bitcoin line is included only
|
||||
// for Lightning-relevant invoices (never card-paid or void) to avoid spuriously
|
||||
// minting a bolt11.
|
||||
export function useInvoicePdf() {
|
||||
const [printing, setPrinting] = createSignal(false)
|
||||
|
||||
async function printInvoice(invoice: Invoice) {
|
||||
if (printing()) return
|
||||
setPrinting(true)
|
||||
try {
|
||||
const items = await listInvoiceItems(invoice.id).catch(() => [] as InvoiceItem[])
|
||||
|
||||
let sats: number | undefined
|
||||
let qrDataUrl: string | undefined
|
||||
if (invoice.method !== "stripe" && invoice.voided_at == null) {
|
||||
try {
|
||||
const bolt11 = await getInvoiceBolt11(invoice.id)
|
||||
sats = Math.round(bolt11.msats / 1000)
|
||||
if (invoice.paid_at == null) {
|
||||
qrDataUrl = await QRCode.toDataURL(bolt11.lnbc, { width: 180, margin: 1 })
|
||||
}
|
||||
} catch {
|
||||
// no bolt11 available — omit the bitcoin line
|
||||
}
|
||||
}
|
||||
|
||||
printHtml(buildHtml({ invoice, items, sats, qrDataUrl }))
|
||||
} finally {
|
||||
setPrinting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return { printInvoice, printing }
|
||||
}
|
||||
|
||||
function buildHtml(opts: { invoice: Invoice; items: InvoiceItem[]; sats?: number; qrDataUrl?: string }): string {
|
||||
const { invoice, items, sats, qrDataUrl } = opts
|
||||
const status = invoiceStatus(invoice)
|
||||
|
||||
const rows = items.length
|
||||
? items
|
||||
.map((i) => `<tr><td>${escapeHtml(i.description || "Charge")}</td><td class="amt">${fmtUsd(i.amount)}</td></tr>`)
|
||||
.join("")
|
||||
: `<tr><td>Relay subscription</td><td class="amt">${fmtUsd(invoice.amount)}</td></tr>`
|
||||
|
||||
const satsRow = sats != null ? `<tr><td>Bitcoin equivalent</td><td class="amt">${sats.toLocaleString()} sats</td></tr>` : ""
|
||||
const methodLine = invoice.method ? `<div>Paid via ${escapeHtml(methodLabels[invoice.method] ?? invoice.method)}</div>` : ""
|
||||
const qr = qrDataUrl
|
||||
? `<div class="qr"><img src="${qrDataUrl}" alt="Lightning invoice QR"/><div class="muted">Scan to pay by Lightning</div></div>`
|
||||
: ""
|
||||
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>Invoice ${escapeHtml(invoice.id)}</title>
|
||||
<style>
|
||||
* { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; color: #111827; box-sizing: border-box; }
|
||||
body { margin: 40px; }
|
||||
h1 { font-size: 20px; margin: 0 0 4px; }
|
||||
.muted { color: #6b7280; font-size: 12px; }
|
||||
.head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; }
|
||||
.badge { text-transform: capitalize; border: 1px solid #d1d5db; border-radius: 9999px; padding: 2px 10px; font-size: 12px; }
|
||||
.meta { font-size: 13px; color: #374151; line-height: 1.6; word-break: break-all; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
|
||||
th, td { text-align: left; padding: 8px 0; border-bottom: 1px solid #e5e7eb; font-size: 14px; }
|
||||
.amt { text-align: right; white-space: nowrap; }
|
||||
tfoot td { font-weight: 600; border-bottom: none; border-top: 2px solid #111827; }
|
||||
.qr { margin-top: 28px; text-align: center; }
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="head">
|
||||
<div><h1>${escapeHtml(PLATFORM_NAME)}</h1><div class="muted">Invoice</div></div>
|
||||
<span class="badge">${status}</span>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div>Invoice ID: ${escapeHtml(invoice.id)}</div>
|
||||
<div>Billed to: ${escapeHtml(invoice.tenant_pubkey)}</div>
|
||||
<div>Issued: ${fmtDate(invoice.created_at)}</div>
|
||||
<div>Period: ${fmtDate(invoice.period_start)} – ${fmtDate(invoice.period_end)}</div>
|
||||
${methodLine}
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Description</th><th class="amt">Amount</th></tr></thead>
|
||||
<tbody>${rows}${satsRow}</tbody>
|
||||
<tfoot><tr><td>Total</td><td class="amt">${fmtUsd(invoice.amount)}</td></tr></tfoot>
|
||||
</table>
|
||||
${qr}
|
||||
</body></html>`
|
||||
}
|
||||
|
||||
function printHtml(html: string) {
|
||||
const iframe = document.createElement("iframe")
|
||||
iframe.style.position = "fixed"
|
||||
iframe.style.right = "0"
|
||||
iframe.style.bottom = "0"
|
||||
iframe.style.width = "0"
|
||||
iframe.style.height = "0"
|
||||
iframe.style.border = "0"
|
||||
document.body.appendChild(iframe)
|
||||
|
||||
const win = iframe.contentWindow
|
||||
const doc = win?.document
|
||||
if (!win || !doc) {
|
||||
iframe.remove()
|
||||
return
|
||||
}
|
||||
|
||||
doc.open()
|
||||
doc.write(html)
|
||||
doc.close()
|
||||
|
||||
const cleanup = () => window.setTimeout(() => iframe.remove(), 1000)
|
||||
win.onafterprint = cleanup
|
||||
|
||||
// Let the iframe lay out (and decode the QR image) before printing.
|
||||
window.setTimeout(() => {
|
||||
win.focus()
|
||||
win.print()
|
||||
window.setTimeout(cleanup, 60000)
|
||||
}, 150)
|
||||
}
|
||||
@@ -104,8 +104,13 @@ export default function useRelayToggles(
|
||||
if (plan_id !== "free") {
|
||||
const needsSetup = await tenantNeedsPaymentSetup()
|
||||
if (needsSetup) {
|
||||
// Materialize the invoice for this upgrade (no collection, no DM) so we
|
||||
// can prompt the tenant to pay it directly. listTenantInvoices reconciles
|
||||
// first, so a just-created invoice is visible here.
|
||||
const invoice = await getLatestOpenInvoice()
|
||||
if (invoice) setPendingInvoice(invoice)
|
||||
if (invoice) {
|
||||
setPendingInvoice(invoice)
|
||||
}
|
||||
setPendingPaymentSetup(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +1,61 @@
|
||||
import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js"
|
||||
import { useSearchParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
|
||||
import PageContainer from "@/components/PageContainer"
|
||||
import LoadingState from "@/components/LoadingState"
|
||||
import PaymentDialog from "@/components/PaymentDialog"
|
||||
import BillingPrompts from "@/components/BillingPrompts"
|
||||
import useMinLoading from "@/components/useMinLoading"
|
||||
import { updateActiveTenant, useTenant } from "@/lib/hooks"
|
||||
import { createPortalSession, getInvoice, invoiceStatus, listTenantInvoices, type Invoice } from "@/lib/api"
|
||||
import { useInvoicePdf } from "@/lib/useInvoicePdf"
|
||||
import { updateActiveTenant } from "@/lib/hooks"
|
||||
import { useBillingStatus } from "@/lib/billing"
|
||||
import { createPortalSession, invoiceStatus, type Invoice } from "@/lib/api"
|
||||
import { account } from "@/lib/state"
|
||||
|
||||
const methodLabels: Record<string, string> = {
|
||||
nwc: "Lightning",
|
||||
stripe: "Card",
|
||||
oob: "Lightning",
|
||||
}
|
||||
|
||||
const invoiceStatusStyles: Record<string, string> = {
|
||||
open: "bg-yellow-50 text-yellow-700 border-yellow-200",
|
||||
paid: "bg-green-50 text-green-700 border-green-200",
|
||||
void: "bg-gray-100 text-gray-500 border-gray-200",
|
||||
}
|
||||
|
||||
export default function Account() {
|
||||
const [tenant, { refetch: refetchTenant }] = useTenant()
|
||||
const [invoices, { refetch: refetchInvoices }] = createResource(() => listTenantInvoices(account()!.pubkey))
|
||||
const billing = useBillingStatus()
|
||||
const [nwcUrl, setNwcUrl] = createSignal("")
|
||||
const [saving, setSaving] = createSignal(false)
|
||||
const [error, setError] = createSignal("")
|
||||
const [selectedInvoice, setSelectedInvoice] = createSignal<Invoice | undefined>()
|
||||
const [portalLoading, setPortalLoading] = createSignal(false)
|
||||
const invoicesLoading = useMinLoading(() => invoices.loading)
|
||||
const invoicesLoading = useMinLoading(() => billing.loading())
|
||||
const { printInvoice, printing } = useInvoicePdf()
|
||||
|
||||
// Deep link: /account?invoice=<id> (e.g. from the billing DM) fetches that
|
||||
// invoice and opens the payment dialog. The fetched invoice takes precedence
|
||||
// over a row the user clicked in the list.
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [deepLinkedInvoice] = createResource(
|
||||
() => searchParams.invoice as string | undefined,
|
||||
(id) => getInvoice(id),
|
||||
)
|
||||
// On landing here (the billing portal returns to this page), refresh billing so
|
||||
// a card just added in the portal — which get_tenant syncs onto the tenant — and
|
||||
// any cleared error show immediately rather than only on the next reconcile.
|
||||
createEffect(() => {
|
||||
if (deepLinkedInvoice.error) setError("Couldn't load that invoice. It may no longer be available.")
|
||||
if (account()?.pubkey) billing.refetch()
|
||||
})
|
||||
const activeInvoice = () => selectedInvoice() ?? deepLinkedInvoice()
|
||||
|
||||
// The backend never returns the stored nwc_url (it's private), so the input is
|
||||
// write-only: we can only act on a newly entered URL, not prefill the saved one.
|
||||
const hasBillingChanges = createMemo(() => nwcUrl().trim().length > 0)
|
||||
|
||||
// The amount to surface: the total of any open invoices, else nothing owed.
|
||||
const balance = createMemo(() => {
|
||||
const due = billing.balance()
|
||||
return due > 0 ? { kind: "due" as const, amount: due } : { kind: "clear" as const, amount: 0 }
|
||||
})
|
||||
|
||||
async function saveBilling() {
|
||||
setError("")
|
||||
setSaving(true)
|
||||
try {
|
||||
const next = nwcUrl().trim()
|
||||
await updateActiveTenant({ nwc_url: next })
|
||||
await updateActiveTenant({ nwc_url: nwcUrl().trim() })
|
||||
setNwcUrl("")
|
||||
await refetchTenant()
|
||||
billing.refetch()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to update billing")
|
||||
} finally {
|
||||
@@ -50,13 +63,6 @@ export default function Account() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleInvoiceDialogClose() {
|
||||
setSelectedInvoice(undefined)
|
||||
// Clearing the query param drops the deep-linked invoice and closes the dialog.
|
||||
if (searchParams.invoice) setSearchParams({ invoice: undefined })
|
||||
void refetchInvoices()
|
||||
}
|
||||
|
||||
async function openPortal() {
|
||||
setPortalLoading(true)
|
||||
try {
|
||||
@@ -74,12 +80,6 @@ export default function Account() {
|
||||
window.location.href = "/"
|
||||
}
|
||||
|
||||
const invoiceStatusStyles: Record<string, string> = {
|
||||
open: "bg-yellow-50 text-yellow-700 border-yellow-200",
|
||||
paid: "bg-green-50 text-green-700 border-green-200",
|
||||
void: "bg-gray-100 text-gray-500 border-gray-200",
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div class="mb-6 py-2 flex items-center justify-between gap-3">
|
||||
@@ -94,10 +94,13 @@ export default function Account() {
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
{/* Billing prompts, emphasized contextually on the billing page. */}
|
||||
<BillingPrompts variant="inline" />
|
||||
|
||||
<section class="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Account Status</h2>
|
||||
<Show when={tenant()}>
|
||||
<Show when={billing.tenant()}>
|
||||
<span class="rounded-full border border-gray-300 bg-gray-100 px-2.5 py-1 text-xs font-medium uppercase tracking-wide text-gray-700">
|
||||
tenant
|
||||
</span>
|
||||
@@ -117,10 +120,22 @@ export default function Account() {
|
||||
{portalLoading() ? "Loading..." : "Manage Billing"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Current balance */}
|
||||
<div class="mb-4 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3">
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Current balance</p>
|
||||
<Show
|
||||
when={balance().kind === "due"}
|
||||
fallback={<p class="text-sm text-gray-600 mt-1">You're all paid up.</p>}
|
||||
>
|
||||
<p class="text-2xl font-bold text-gray-900 mt-0.5">${(balance().amount / 100).toFixed(2)} <span class="text-sm font-normal text-gray-500">due</span></p>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Enable automatic payments by providing your Nostr Wallet Connect URL.
|
||||
Enable automatic payments by providing your Nostr Wallet Connect URL, or add a card via Manage Billing.
|
||||
</p>
|
||||
<Show when={tenant()?.nwc_is_set}>
|
||||
<Show when={billing.tenant()?.nwc_is_set}>
|
||||
<p class="text-sm text-green-700 mb-4">A wallet is connected. Enter a new URL to replace it.</p>
|
||||
</Show>
|
||||
<div class="flex gap-2">
|
||||
@@ -140,17 +155,6 @@ export default function Account() {
|
||||
{saving() ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
<Show when={tenant()?.churned_at}>
|
||||
<p class="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
Your account is past due and some relays have been paused. Update your payment method below to restore service.
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={tenant()?.nwc_error}>
|
||||
<p class="mt-3 text-sm text-red-600">Lightning auto-payment failed: {tenant()!.nwc_error}</p>
|
||||
</Show>
|
||||
<Show when={tenant()?.stripe_error}>
|
||||
<p class="mt-3 text-sm text-red-600">Card auto-payment failed: {tenant()!.stripe_error}</p>
|
||||
</Show>
|
||||
<Show when={error()}>
|
||||
<p class="mt-3 text-sm text-red-600">{error()}</p>
|
||||
</Show>
|
||||
@@ -162,9 +166,9 @@ export default function Account() {
|
||||
<LoadingState message="Loading invoices..." paddingClass="py-8" />
|
||||
</Show>
|
||||
<Show when={!invoicesLoading()}>
|
||||
<Show when={(invoices()?.length ?? 0) > 0} fallback={<p class="text-gray-500 text-sm">No invoices yet.</p>}>
|
||||
<Show when={billing.invoices().length > 0} fallback={<p class="text-gray-500 text-sm">No invoices yet.</p>}>
|
||||
<ul class="space-y-3">
|
||||
<For each={invoices()}>
|
||||
<For each={billing.invoices()}>
|
||||
{(invoice) => {
|
||||
const status = () => invoiceStatus(invoice)
|
||||
const isOpen = () => status() === "open"
|
||||
@@ -186,6 +190,9 @@ export default function Account() {
|
||||
<span class="font-medium text-gray-900">
|
||||
${(invoice.amount / 100).toFixed(2)}
|
||||
</span>
|
||||
<Show when={invoice.method}>
|
||||
<span class="text-xs text-gray-500"> · paid via {methodLabels[invoice.method!] ?? invoice.method}</span>
|
||||
</Show>
|
||||
<Show when={invoice.period_start && invoice.period_end}>
|
||||
<p class="text-xs text-gray-500 mt-0.5">{periodLabel()}</p>
|
||||
</Show>
|
||||
@@ -197,6 +204,17 @@ export default function Account() {
|
||||
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${statusStyle()}`}>
|
||||
{status()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void printInvoice(invoice)
|
||||
}}
|
||||
disabled={printing()}
|
||||
class="text-xs font-medium text-gray-500 hover:text-gray-800 underline disabled:opacity-50"
|
||||
>
|
||||
PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@@ -209,12 +227,15 @@ export default function Account() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Show when={activeInvoice()}>
|
||||
<Show when={selectedInvoice()}>
|
||||
{(invoice) => (
|
||||
<PaymentDialog
|
||||
invoice={invoice()}
|
||||
open={true}
|
||||
onClose={handleInvoiceDialogClose}
|
||||
onClose={() => {
|
||||
setSelectedInvoice(undefined)
|
||||
billing.refetch()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, createSignal, Show } from "solid-js"
|
||||
import { createEffect, createResource, createSignal, onCleanup, Show } from "solid-js"
|
||||
import BackLink from "@/components/BackLink"
|
||||
import PageContainer from "@/components/PageContainer"
|
||||
import PaymentDialog from "@/components/PaymentDialog"
|
||||
@@ -9,9 +9,10 @@ import ResourceState from "@/components/ResourceState"
|
||||
import useMinLoading from "@/components/useMinLoading"
|
||||
import ActivityFeed from "@/components/ActivityFeed"
|
||||
import { listRelayMembers } from "@/lib/api"
|
||||
import { getLatestOpenInvoice, useRelay, useRelayActivity, useTenant } from "@/lib/hooks"
|
||||
import { useRelay, useRelayActivity } from "@/lib/hooks"
|
||||
import useRelayToggles from "@/lib/useRelayToggles"
|
||||
import { plans } from "@/lib/state"
|
||||
import { setBillingFlowActive } from "@/lib/billing"
|
||||
import { refetchBilling } from "@/lib/state"
|
||||
|
||||
export default function RelayDetail() {
|
||||
const params = useParams()
|
||||
@@ -30,10 +31,7 @@ export default function RelayDetail() {
|
||||
const [activity] = useRelayActivity(relayId)
|
||||
const { busy, handleDeactivate, handleReactivate, handleUpdatePlan, pendingInvoice, clearPendingInvoice, pendingPaymentSetup, clearPendingPaymentSetup, toggles } = useRelayToggles(relayId, relay, { refetch, mutate })
|
||||
|
||||
const [tenant, { refetch: refetchTenant }] = useTenant()
|
||||
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
|
||||
const [invoiceDialogOpen, setInvoiceDialogOpen] = createSignal(false)
|
||||
const [paymentBannerDismissed, setPaymentBannerDismissed] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
if (pendingPaymentSetup() && !pendingInvoice()) {
|
||||
@@ -42,25 +40,10 @@ export default function RelayDetail() {
|
||||
}
|
||||
})
|
||||
|
||||
const isPaidRelay = createMemo(() => {
|
||||
const r = relay()
|
||||
if (!r) return false
|
||||
const plan = plans().find(p => p.id === r.plan_id)
|
||||
return !!(plan && plan.amount > 0)
|
||||
})
|
||||
|
||||
const [openInvoice, { refetch: refetchOpenInvoice }] = createResource(
|
||||
isPaidRelay,
|
||||
async (paid) => paid ? getLatestOpenInvoice() : null
|
||||
)
|
||||
|
||||
const showPaymentNudge = createMemo(() => {
|
||||
if (paymentBannerDismissed()) return false
|
||||
if (!isPaidRelay()) return false
|
||||
const t = tenant()
|
||||
if (!t) return false
|
||||
return !t.nwc_is_set && !t.stripe_payment_method_id
|
||||
})
|
||||
// Suppress the shared banner's redundant pay/setup prompts while this page's
|
||||
// own inline plan-change modals are open.
|
||||
createEffect(() => setBillingFlowActive(Boolean(pendingInvoice()) || paymentSetupOpen()))
|
||||
onCleanup(() => setBillingFlowActive(false))
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -69,44 +52,6 @@ export default function RelayDetail() {
|
||||
<Show when={!loading() && relay()}>
|
||||
{(r) => (
|
||||
<div class="space-y-6 mb-6">
|
||||
<Show when={showPaymentNudge()}>
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-amber-800">Payment setup recommended</p>
|
||||
<p class="text-sm text-amber-700 mt-1">
|
||||
This relay is on a paid plan. Invoices are due when your subscription starts. Set up NWC or Stripe for automatic payments, or pay open invoices via Lightning.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 shrink-0">
|
||||
<Show when={openInvoice()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInvoiceDialogOpen(true)}
|
||||
class="text-sm font-medium text-amber-800 underline hover:text-amber-900 whitespace-nowrap"
|
||||
>
|
||||
Pay invoice
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentSetupOpen(true)}
|
||||
class="text-sm font-medium text-amber-800 underline hover:text-amber-900 whitespace-nowrap"
|
||||
>
|
||||
Set up payments
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentBannerDismissed(true)}
|
||||
aria-label="Dismiss"
|
||||
class="text-amber-500 hover:text-amber-800 shrink-0"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<RelayDetailCard
|
||||
relay={r()}
|
||||
currentMembers={members()?.length}
|
||||
@@ -129,20 +74,7 @@ export default function RelayDetail() {
|
||||
open={true}
|
||||
onClose={() => {
|
||||
clearPendingInvoice()
|
||||
void refetchTenant()
|
||||
void refetchOpenInvoice()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={openInvoice()}>
|
||||
{(inv) => (
|
||||
<PaymentDialog
|
||||
invoice={inv()!}
|
||||
open={invoiceDialogOpen()}
|
||||
onClose={() => {
|
||||
setInvoiceDialogOpen(false)
|
||||
void refetchOpenInvoice()
|
||||
void refetchBilling()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -151,7 +83,7 @@ export default function RelayDetail() {
|
||||
open={paymentSetupOpen()}
|
||||
onClose={() => {
|
||||
setPaymentSetupOpen(false)
|
||||
void refetchTenant()
|
||||
void refetchBilling()
|
||||
}}
|
||||
/>
|
||||
</PageContainer>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { createEffect, createSignal, onCleanup, Show } from "solid-js"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import BackLink from "@/components/BackLink"
|
||||
import PageContainer from "@/components/PageContainer"
|
||||
@@ -7,6 +7,8 @@ import PaymentSetup from "@/components/PaymentSetup"
|
||||
import RelayForm, { type RelayFormValues } from "@/components/RelayForm"
|
||||
import { createRelayForActiveTenant, getLatestOpenInvoice, tenantNeedsPaymentSetup } from "@/lib/hooks"
|
||||
import type { Invoice } from "@/lib/api"
|
||||
import { setBillingFlowActive } from "@/lib/billing"
|
||||
import { refetchBilling } from "@/lib/state"
|
||||
|
||||
export default function RelayNew() {
|
||||
const navigate = useNavigate()
|
||||
@@ -14,13 +16,22 @@ export default function RelayNew() {
|
||||
const [paymentSetupOpen, setPaymentSetupOpen] = createSignal(false)
|
||||
let createdRelayId = ""
|
||||
|
||||
// While this flow's inline modals are open, suppress the shared banner's
|
||||
// overlapping pay/setup prompts (it still surfaces churn / method errors).
|
||||
createEffect(() => setBillingFlowActive(Boolean(pendingInvoice()) || paymentSetupOpen()))
|
||||
onCleanup(() => setBillingFlowActive(false))
|
||||
|
||||
async function handleSubmit(values: RelayFormValues) {
|
||||
const relay = await createRelayForActiveTenant(values)
|
||||
createdRelayId = relay.id
|
||||
void refetchBilling()
|
||||
|
||||
if (values.plan_id !== "free") {
|
||||
const needsSetup = await tenantNeedsPaymentSetup()
|
||||
if (needsSetup) {
|
||||
// Materialize the invoice for this change (no collection, no DM) so we
|
||||
// can prompt the tenant to pay it directly. listTenantInvoices reconciles
|
||||
// first, so a just-created invoice is visible here.
|
||||
const invoice = await getLatestOpenInvoice()
|
||||
if (invoice) {
|
||||
setPendingInvoice(invoice)
|
||||
@@ -41,6 +52,7 @@ export default function RelayNew() {
|
||||
|
||||
function handleSetupClose() {
|
||||
setPaymentSetupOpen(false)
|
||||
void refetchBilling()
|
||||
navigate(`/relays/${createdRelayId}`)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user