Compare commits
3 Commits
f7bd3e53fe
...
0018a5d4f3
| Author | SHA1 | Date | |
|---|---|---|---|
| 0018a5d4f3 | |||
| ae3e1c316e | |||
| d5047dedb1 |
@@ -2,10 +2,13 @@ CREATE TABLE IF NOT EXISTS tenant (
|
||||
pubkey TEXT PRIMARY KEY,
|
||||
nwc_url TEXT NOT NULL DEFAULT '',
|
||||
nwc_error TEXT,
|
||||
stripe_error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
billing_anchor INTEGER,
|
||||
stripe_customer_id TEXT NOT NULL,
|
||||
renewed_at INTEGER
|
||||
stripe_payment_method_id TEXT,
|
||||
renewed_at INTEGER,
|
||||
churned_at INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activity (
|
||||
@@ -45,12 +48,13 @@ CREATE TABLE IF NOT EXISTS relay (
|
||||
CREATE TABLE IF NOT EXISTS invoice (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_pubkey TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('open','paid','void','churn')),
|
||||
method TEXT CHECK (method IS NULL OR method IN ('nwc','stripe','oob')),
|
||||
amount INTEGER NOT NULL,
|
||||
period_start INTEGER NOT NULL,
|
||||
period_end INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
paid_at INTEGER,
|
||||
voided_at INTEGER,
|
||||
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
|
||||
);
|
||||
|
||||
@@ -96,6 +100,9 @@ CREATE INDEX IF NOT EXISTS idx_relay_tenant_pubkey ON relay (tenant_pubkey);
|
||||
|
||||
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.
|
||||
CREATE INDEX IF NOT EXISTS idx_invoice_open ON invoice (tenant_pubkey, created_at) WHERE paid_at IS NULL AND voided_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invoice_item_invoice ON invoice_item (invoice_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invoice_item_outstanding ON invoice_item (tenant_pubkey) WHERE invoice_id IS NULL;
|
||||
@@ -104,5 +111,3 @@ CREATE INDEX IF NOT EXISTS idx_invoice_item_outstanding ON invoice_item (tenant_
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_invoice_item_activity ON invoice_item (activity_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bolt11_invoice_created ON bolt11 (invoice_id, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_intent_invoice ON intent (invoice_id);
|
||||
|
||||
+123
-78
@@ -4,12 +4,20 @@ use std::time::Duration;
|
||||
use crate::bitcoin;
|
||||
use crate::command;
|
||||
use crate::env;
|
||||
use crate::models::{Activity, Bolt11, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, Snapshot, Tenant};
|
||||
use crate::models::{
|
||||
Activity, Bolt11, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, Snapshot, Tenant,
|
||||
};
|
||||
use crate::query;
|
||||
use crate::robot::Robot;
|
||||
use crate::stripe::Stripe;
|
||||
use crate::wallet::Wallet;
|
||||
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(60 * 60);
|
||||
const GRACE_PERIOD_SECS: i64 = 7 * 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;
|
||||
|
||||
/// Owns subscription billing: it reconciles tenant activity into invoice items,
|
||||
/// renews subscriptions each period, and collects payment (Lightning, then a
|
||||
/// card on file, then a manual DM link).
|
||||
@@ -63,16 +71,27 @@ impl Billing {
|
||||
|
||||
// --- Reconciliation of activity/renewals ---
|
||||
|
||||
/// Lists billable activity, setting the tenant's billing anchor to the first
|
||||
/// activity in the process. Generates an invoice for the current period if due
|
||||
/// for renewal or any billable activities have occurred. Attempts payment if
|
||||
/// an invoice is generated.
|
||||
/// 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<()> {
|
||||
let mut tenant = tenant.clone();
|
||||
|
||||
let activities = query::list_billable_activity(&tenant.pubkey).await?;
|
||||
|
||||
// A churned tenant with fresh billable activity is using the service
|
||||
// again: re-activate billing (and restore their relays) before billing it.
|
||||
if tenant.churned_at.is_some() && !activities.is_empty() {
|
||||
let relays = query::list_relays_for_tenant(&tenant.pubkey).await?;
|
||||
command::reactivate_tenant(&tenant.pubkey, &relays).await?;
|
||||
tenant.churned_at = None;
|
||||
}
|
||||
|
||||
// Reconcile all activity, setting the tenant's billing anchor on the first
|
||||
// positive-balance line item if not already set.
|
||||
for activity in query::list_billable_activity_for_tenant(&tenant.pubkey).await? {
|
||||
// activity if not already set.
|
||||
for activity in activities {
|
||||
if tenant.billing_anchor.is_none() {
|
||||
tenant.billing_anchor = Some(activity.created_at);
|
||||
command::set_tenant_billing_anchor(&tenant).await?;
|
||||
@@ -81,20 +100,19 @@ impl Billing {
|
||||
self.reconcile_activity(&tenant, &activity).await?;
|
||||
}
|
||||
|
||||
// If the tenant has no billing anchor, they have nothing to bill
|
||||
let Some(period) = BillingPeriod::current(&tenant) else {
|
||||
return Ok(());
|
||||
};
|
||||
// If the tenant has a billing anchor, renew the current period if due and
|
||||
// claim any outstanding items onto an invoice.
|
||||
if let Some(period) = BillingPeriod::current(&tenant) {
|
||||
if tenant.renewed_at.is_none_or(|at| at < period.start) {
|
||||
self.reconcile_renewal(&tenant, &period).await?;
|
||||
}
|
||||
|
||||
// If tenant is due for renewal, bill any active relays.
|
||||
if tenant.renewed_at.is_none_or(|at| at < period.start) {
|
||||
self.reconcile_renewal(&tenant, &period).await?;
|
||||
command::create_invoice(&tenant, &period).await?;
|
||||
}
|
||||
|
||||
// Create the invoice, but only if non-zero and attempt payment
|
||||
if let Some(invoice) = command::create_invoice(&tenant, &period).await? {
|
||||
self.attempt_payment(&tenant, &invoice).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?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -127,9 +145,10 @@ impl Billing {
|
||||
}
|
||||
}
|
||||
|
||||
/// A prorated charge (or credit, with `sign` = -1) for the relay's current
|
||||
/// plan, covering the fraction of the period remaining at the activity.
|
||||
/// `None` for a missing relay or a free plan.
|
||||
/// A prorated charge (or credit, with `sign` = -1) for the plan recorded on
|
||||
/// the activity's snapshot — the plan as of the activity, not the relay's
|
||||
/// current plan — covering the fraction of the period remaining at the
|
||||
/// activity. `None` for a free plan.
|
||||
async fn make_prorated_item(
|
||||
&self,
|
||||
tenant: &Tenant,
|
||||
@@ -137,10 +156,8 @@ impl Billing {
|
||||
sign: i64,
|
||||
description: &str,
|
||||
) -> Result<Option<InvoiceItem>> {
|
||||
let Some(relay) = query::get_relay(&activity.resource_id).await? else {
|
||||
return Err(anyhow!("activity resource was not a valid relay"));
|
||||
};
|
||||
let plan = query::get_plan(&relay.plan_id)?;
|
||||
let Snapshot::Relay { plan: plan_id, .. } = &*activity.snapshot;
|
||||
let plan = query::get_plan(plan_id)?;
|
||||
if plan.amount <= 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -253,6 +270,10 @@ impl Billing {
|
||||
|
||||
// --- Payments ---
|
||||
|
||||
/// 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
|
||||
/// next succeeds.
|
||||
pub async fn attempt_payment(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> {
|
||||
let mut error_message: Option<String> = None;
|
||||
|
||||
@@ -264,7 +285,16 @@ impl Billing {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Payment method on file: if the tenant has one saved, charge it via Stripe.
|
||||
// 2. Out-of-band lightning: catches partially failed NWC or manual payment
|
||||
if let Some(bolt11) = query::get_bolt11_for_invoice(&invoice.id).await?
|
||||
&& bolt11.settled_at.is_none()
|
||||
&& self.wallet.is_settled(&bolt11.lnbc).await.unwrap_or(false)
|
||||
{
|
||||
command::settle_invoice_out_of_band(&bolt11.id, &invoice.id).await?;
|
||||
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?
|
||||
{
|
||||
@@ -273,11 +303,11 @@ impl Billing {
|
||||
.await
|
||||
{
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => error_message = Some(format!("{e}")),
|
||||
Err(e) => error_message = error_message.or_else(|| Some(format!("{e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Manual payment: DM a link to the in-app payment page for this invoice.
|
||||
// 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 {
|
||||
tracing::error!(
|
||||
@@ -291,26 +321,24 @@ impl Billing {
|
||||
}
|
||||
|
||||
async fn attempt_payment_using_nwc(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> {
|
||||
let nwc_url = env::get().decrypt(&tenant.nwc_url)?;
|
||||
let tenant_wallet = Wallet::from_url(&nwc_url)?;
|
||||
let bolt11 = self.ensure_bolt11(&invoice.id).await?;
|
||||
let result: Result<()> = async {
|
||||
let nwc_url = env::get().decrypt(&tenant.nwc_url)?;
|
||||
let tenant_wallet = Wallet::from_url(&nwc_url)?;
|
||||
let bolt11 = self.ensure_bolt11(invoice).await?;
|
||||
|
||||
match tenant_wallet.pay_invoice(bolt11.lnbc.clone()).await {
|
||||
Ok(()) => {
|
||||
command::clear_tenant_nwc_error(&tenant.pubkey).await?;
|
||||
command::mark_bolt11_settled(&bolt11.id).await?;
|
||||
command::mark_invoice_paid(&invoice.id, "nwc").await
|
||||
}
|
||||
Err(pay_error) => {
|
||||
// The pay request errored, but the invoice may have been paid out of band.
|
||||
if self.wallet.is_settled(&bolt11.lnbc).await.unwrap_or(false) {
|
||||
command::mark_bolt11_settled(&bolt11.id).await?;
|
||||
command::mark_invoice_paid(&invoice.id, "oob").await
|
||||
} else {
|
||||
Err(pay_error)
|
||||
}
|
||||
}
|
||||
tenant_wallet.pay_invoice(bolt11.lnbc.clone()).await?;
|
||||
|
||||
command::settle_invoice_via_nwc(&tenant.pubkey, &bolt11.id, &invoice.id).await
|
||||
}
|
||||
.await;
|
||||
|
||||
// Record the failure on the tenant (to warn them in the UI) but still
|
||||
// surface it, so the cascade can fall through and summarize it in the DM.
|
||||
if let Err(error) = &result {
|
||||
command::set_tenant_nwc_error(&tenant.pubkey, &format!("{error}")).await?;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn attempt_payment_using_stripe(
|
||||
@@ -319,23 +347,28 @@ impl Billing {
|
||||
invoice: &Invoice,
|
||||
payment_method_id: &str,
|
||||
) -> Result<()> {
|
||||
let amount = self.get_invoice_amount(&invoice.id).await?;
|
||||
let result: Result<()> = async {
|
||||
let intent_id = self
|
||||
.stripe
|
||||
.create_payment_intent(
|
||||
&tenant.stripe_customer_id,
|
||||
payment_method_id,
|
||||
&invoice.id,
|
||||
invoice.amount,
|
||||
"usd",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// A decline or an off-session authentication demand comes back as Err, so
|
||||
// the cascade falls back to the manual DM.
|
||||
let intent_id = self
|
||||
.stripe
|
||||
.create_payment_intent(
|
||||
&tenant.stripe_customer_id,
|
||||
payment_method_id,
|
||||
&invoice.id,
|
||||
amount,
|
||||
"usd",
|
||||
)
|
||||
.await?;
|
||||
command::settle_invoice_via_stripe(&tenant.pubkey, &intent_id, &invoice.id).await
|
||||
}
|
||||
.await;
|
||||
|
||||
command::insert_intent(&intent_id, &invoice.id).await?;
|
||||
command::mark_invoice_paid(&invoice.id, "stripe").await
|
||||
// Record the failure on the tenant (to warn them in the UI) but still
|
||||
// surface it, so the cascade can fall through and summarize it in the DM.
|
||||
if let Err(error) = &result {
|
||||
command::set_tenant_stripe_error(&tenant.pubkey, &format!("{error}")).await?;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn attempt_payment_using_dm(
|
||||
@@ -358,20 +391,38 @@ impl Billing {
|
||||
self.robot.send_dm(&tenant.pubkey, &dm_message).await
|
||||
}
|
||||
|
||||
// --- Invoice utils ---
|
||||
// --- Dunning ---
|
||||
|
||||
pub async fn get_invoice_amount(&self, invoice_id: &str) -> Result<i64> {
|
||||
let invoice_items = query::get_invoice_items_for_invoice(invoice_id).await?;
|
||||
/// 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(());
|
||||
};
|
||||
|
||||
Ok(invoice_items.iter().map(|item| item.amount).sum())
|
||||
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_id: &str) -> Result<Bolt11> {
|
||||
pub async fn ensure_bolt11(&self, invoice: &Invoice) -> Result<Bolt11> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
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)
|
||||
{
|
||||
return Ok(existing);
|
||||
@@ -379,11 +430,10 @@ impl Billing {
|
||||
|
||||
let expiry: i64 = 3600;
|
||||
let info = "Relay subscription payment";
|
||||
let amount = self.get_invoice_amount(invoice_id).await?;
|
||||
let msats = bitcoin::fiat_to_msats(amount, "usd").await?;
|
||||
let msats = bitcoin::fiat_to_msats(invoice.amount, "usd").await?;
|
||||
let lnbc = self.wallet.make_invoice(msats, info, expiry as u64).await?;
|
||||
|
||||
command::insert_bolt11(invoice_id, &lnbc, msats as i64, now + expiry)
|
||||
command::insert_bolt11(&invoice.id, &lnbc, msats as i64, now + expiry)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("failed to insert bolt11"))
|
||||
}
|
||||
@@ -393,11 +443,11 @@ impl Billing {
|
||||
/// settled on the robot wallet, mark it paid and return the refreshed record;
|
||||
/// otherwise return it unchanged. Meant to run before presenting a payable
|
||||
/// invoice so we never hand back one that's already been paid.
|
||||
pub async fn ensure_and_reconcile_bolt11(&self, invoice_id: &str) -> Result<Bolt11> {
|
||||
let bolt11 = self.ensure_bolt11(invoice_id).await?;
|
||||
pub async fn ensure_and_reconcile_bolt11(&self, invoice: &Invoice) -> Result<Bolt11> {
|
||||
let bolt11 = self.ensure_bolt11(invoice).await?;
|
||||
|
||||
if bolt11.settled_at.is_none() && self.wallet.is_settled(&bolt11.lnbc).await? {
|
||||
command::mark_bolt11_settled(&bolt11.id).await?;
|
||||
command::settle_invoice_out_of_band(&bolt11.id, &invoice.id).await?;
|
||||
|
||||
// Re-fetch so the caller sees that it's been settled.
|
||||
Ok(query::get_bolt11(&bolt11.id).await?.unwrap_or(bolt11))
|
||||
@@ -407,11 +457,6 @@ impl Billing {
|
||||
}
|
||||
}
|
||||
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(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 = "NWC auto-payment failed:";
|
||||
const USER_ERROR_MAX_CHARS: usize = 240;
|
||||
|
||||
/// One tenant's monthly billing period containing some timestamp, anchored at
|
||||
/// the tenant's `billing_anchor`. Half-open `[start, end)` so a moment at
|
||||
/// exactly `end` belongs to the next period.
|
||||
|
||||
+229
-64
@@ -43,14 +43,80 @@ pub async fn set_tenant_billing_anchor(tenant: &Tenant) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_tenant_nwc_error(pubkey: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE tenant SET nwc_error = NULL WHERE pubkey = ?")
|
||||
pub async fn set_tenant_nwc_error(pubkey: &str, error: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE tenant SET nwc_error = ? WHERE pubkey = ?")
|
||||
.bind(error)
|
||||
.bind(pubkey)
|
||||
.execute(pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_tenant_stripe_error(pubkey: &str, error: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE tenant SET stripe_error = ? WHERE pubkey = ?")
|
||||
.bind(error)
|
||||
.bind(pubkey)
|
||||
.execute(pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically churn a tenant whose grace period has elapsed: set the churn
|
||||
/// marker, mark every active relay delinquent, and void the unpaid invoices.
|
||||
pub async fn churn_tenant(tenant_pubkey: &str, now: i64, relays: &[Relay]) -> Result<()> {
|
||||
let activities = with_tx(async |tx| {
|
||||
set_tenant_churned_at_tx(tx, tenant_pubkey, Some(now)).await?;
|
||||
|
||||
let mut activities = Vec::new();
|
||||
for relay in relays {
|
||||
if relay.status == RELAY_STATUS_ACTIVE {
|
||||
let activity =
|
||||
set_relay_status_tx(tx, relay, RELAY_STATUS_DELINQUENT, "mark_relay_delinquent")
|
||||
.await?;
|
||||
activities.push(activity);
|
||||
}
|
||||
}
|
||||
|
||||
void_open_invoices_tx(tx, tenant_pubkey).await?;
|
||||
|
||||
Ok(activities)
|
||||
})
|
||||
.await?;
|
||||
|
||||
for activity in activities {
|
||||
publish(activity);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically re-activate a churned tenant: clear the churn marker, restore every
|
||||
/// delinquent relay to active, and void any still-open invoices.
|
||||
pub async fn reactivate_tenant(tenant_pubkey: &str, relays: &[Relay]) -> Result<()> {
|
||||
let activities = with_tx(async |tx| {
|
||||
set_tenant_churned_at_tx(tx, tenant_pubkey, None).await?;
|
||||
|
||||
let mut activities = Vec::new();
|
||||
for relay in relays {
|
||||
if relay.status == RELAY_STATUS_DELINQUENT {
|
||||
let activity =
|
||||
set_relay_status_tx(tx, relay, RELAY_STATUS_ACTIVE, "unmark_relay_delinquent")
|
||||
.await?;
|
||||
activities.push(activity);
|
||||
}
|
||||
}
|
||||
|
||||
void_open_invoices_tx(tx, tenant_pubkey).await?;
|
||||
|
||||
Ok(activities)
|
||||
})
|
||||
.await?;
|
||||
|
||||
for activity in activities {
|
||||
publish(activity);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Relays ---
|
||||
|
||||
pub async fn create_relay(relay: &Relay) -> Result<()> {
|
||||
@@ -140,25 +206,9 @@ pub async fn deactivate_relay(relay: &Relay) -> Result<()> {
|
||||
set_relay_status(relay, RELAY_STATUS_INACTIVE, "deactivate_relay").await
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // wired up by the delinquency flow (not yet implemented)
|
||||
pub async fn mark_relay_delinquent(relay: &Relay) -> Result<()> {
|
||||
set_relay_status(relay, RELAY_STATUS_DELINQUENT, "mark_relay_delinquent").await
|
||||
}
|
||||
|
||||
async fn set_relay_status(relay: &Relay, status: &str, activity_type: &str) -> Result<()> {
|
||||
let activity = with_tx(async |tx| {
|
||||
sqlx::query("UPDATE relay SET status = ?, synced = 0 WHERE id = ?")
|
||||
.bind(status)
|
||||
.bind(&relay.id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
let snapshot = Snapshot::Relay {
|
||||
plan: relay.plan_id.clone(),
|
||||
status: status.to_string(),
|
||||
};
|
||||
insert_activity_tx(tx, activity_type, &relay.id, snapshot).await
|
||||
})
|
||||
.await?;
|
||||
let activity =
|
||||
with_tx(async |tx| set_relay_status_tx(tx, relay, status, activity_type).await).await?;
|
||||
publish(activity);
|
||||
Ok(())
|
||||
}
|
||||
@@ -289,7 +339,7 @@ pub async fn create_invoice(tenant: &Tenant, period: &BillingPeriod) -> Result<O
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let invoice = insert_invoice_tx(tx, &tenant, &period).await?;
|
||||
let invoice = insert_invoice_tx(tx, &tenant, &period, total).await?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE invoice_item SET invoice_id = ?
|
||||
@@ -305,16 +355,45 @@ pub async fn create_invoice(tenant: &Tenant, period: &BillingPeriod) -> Result<O
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn mark_invoice_paid(invoice_id: &str, method: &str) -> Result<()> {
|
||||
let updated_at = chrono::Utc::now().timestamp();
|
||||
// --- Payment settlement ---
|
||||
|
||||
sqlx::query("UPDATE invoice SET status = 'paid', method = ?, updated_at = ? WHERE id = ?")
|
||||
.bind(method)
|
||||
.bind(updated_at)
|
||||
.bind(invoice_id)
|
||||
.execute(pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
/// Atomically record an NWC-settled invoice: clear the tenant's stored NWC error,
|
||||
/// mark the bolt11 settled, and mark the invoice paid.
|
||||
pub async fn settle_invoice_via_nwc(
|
||||
tenant_pubkey: &str,
|
||||
bolt11_id: &str,
|
||||
invoice_id: &str,
|
||||
) -> Result<()> {
|
||||
with_tx(async |tx| {
|
||||
clear_tenant_nwc_error_tx(tx, tenant_pubkey).await?;
|
||||
mark_bolt11_settled_tx(tx, bolt11_id).await?;
|
||||
mark_invoice_paid_tx(tx, invoice_id, "nwc").await
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Atomically record a Lightning settlement that happened out of band.
|
||||
pub async fn settle_invoice_out_of_band(bolt11_id: &str, invoice_id: &str) -> Result<()> {
|
||||
with_tx(async |tx| {
|
||||
mark_bolt11_settled_tx(tx, bolt11_id).await?;
|
||||
mark_invoice_paid_tx(tx, invoice_id, "oob").await
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Atomically record a Stripe-settled invoice: persist the PaymentIntent, clear
|
||||
/// the tenant's stored Stripe error, and mark the invoice paid.
|
||||
pub async fn settle_invoice_via_stripe(
|
||||
tenant_pubkey: &str,
|
||||
intent_id: &str,
|
||||
invoice_id: &str,
|
||||
) -> Result<()> {
|
||||
with_tx(async |tx| {
|
||||
insert_intent_tx(tx, intent_id, invoice_id).await?;
|
||||
clear_tenant_stripe_error_tx(tx, tenant_pubkey).await?;
|
||||
mark_invoice_paid_tx(tx, invoice_id, "stripe").await
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// --- Bolt11 records ---
|
||||
@@ -342,37 +421,6 @@ pub async fn insert_bolt11(
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn mark_bolt11_settled(bolt11_id: &str) -> Result<()> {
|
||||
let settled_at = chrono::Utc::now().timestamp();
|
||||
|
||||
sqlx::query("UPDATE bolt11 SET settled_at = ? WHERE id = ?")
|
||||
.bind(settled_at)
|
||||
.bind(bolt11_id)
|
||||
.execute(pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Intents ---
|
||||
|
||||
/// Record the Stripe PaymentIntent that paid an invoice. Keyed by the Stripe
|
||||
/// PaymentIntent id, so it's idempotent: a retried (idempotent) charge returns
|
||||
/// the same id and the re-insert is a no-op rather than a primary-key conflict.
|
||||
pub async fn insert_intent(intent_id: &str, invoice_id: &str) -> Result<()> {
|
||||
let created_at = chrono::Utc::now().timestamp();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO intent (id, invoice_id, created_at)
|
||||
VALUES (?, ?, ?) ON CONFLICT(id) DO NOTHING",
|
||||
)
|
||||
.bind(intent_id)
|
||||
.bind(invoice_id)
|
||||
.bind(created_at)
|
||||
.execute(pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Internal utils that take an explicit transaction ---
|
||||
|
||||
async fn insert_activity_tx(
|
||||
@@ -425,20 +473,21 @@ async fn insert_invoice_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
tenant: &Tenant,
|
||||
period: &BillingPeriod,
|
||||
amount: i64,
|
||||
) -> Result<Invoice> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let invoice_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
Ok(sqlx::query_as::<_, Invoice>(
|
||||
"INSERT INTO invoice (id, tenant_pubkey, status, period_start, period_end, created_at, updated_at)
|
||||
VALUES (?, ?, 'open', ?, ?, ?, ?) RETURNING *",
|
||||
"INSERT INTO invoice (id, tenant_pubkey, amount, period_start, period_end, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?) RETURNING *",
|
||||
)
|
||||
.bind(invoice_id)
|
||||
.bind(&tenant.pubkey)
|
||||
.bind(amount)
|
||||
.bind(&period.start)
|
||||
.bind(period.end)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?)
|
||||
}
|
||||
@@ -478,3 +527,119 @@ async fn mark_activity_billed_tx(
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Set a relay's status (and flag it for re-sync), recording the matching
|
||||
/// activity. Returns the activity so the caller can `publish` it after the
|
||||
/// enclosing transaction commits.
|
||||
async fn set_relay_status_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
relay: &Relay,
|
||||
status: &str,
|
||||
activity_type: &str,
|
||||
) -> Result<Activity> {
|
||||
sqlx::query("UPDATE relay SET status = ?, synced = 0 WHERE id = ?")
|
||||
.bind(status)
|
||||
.bind(&relay.id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
let snapshot = Snapshot::Relay {
|
||||
plan: relay.plan_id.clone(),
|
||||
status: status.to_string(),
|
||||
};
|
||||
insert_activity_tx(tx, activity_type, &relay.id, snapshot).await
|
||||
}
|
||||
|
||||
async fn mark_bolt11_settled_tx(tx: &mut Transaction<'_, Sqlite>, bolt11_id: &str) -> Result<()> {
|
||||
let settled_at = chrono::Utc::now().timestamp();
|
||||
|
||||
sqlx::query("UPDATE bolt11 SET settled_at = ? WHERE id = ?")
|
||||
.bind(settled_at)
|
||||
.bind(bolt11_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mark_invoice_paid_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
invoice_id: &str,
|
||||
method: &str,
|
||||
) -> Result<()> {
|
||||
let paid_at = chrono::Utc::now().timestamp();
|
||||
|
||||
sqlx::query("UPDATE invoice SET method = ?, paid_at = ? WHERE id = ?")
|
||||
.bind(method)
|
||||
.bind(paid_at)
|
||||
.bind(invoice_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
let voided_at = chrono::Utc::now().timestamp();
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE invoice SET voided_at = ?
|
||||
WHERE tenant_pubkey = ? AND paid_at IS NULL AND voided_at IS NULL",
|
||||
)
|
||||
.bind(voided_at)
|
||||
.bind(tenant_pubkey)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set or clear the tenant's churn marker. Set when an invoice ages past the
|
||||
/// grace period, cleared when billing is re-activated.
|
||||
async fn set_tenant_churned_at_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
pubkey: &str,
|
||||
churned_at: Option<i64>,
|
||||
) -> Result<()> {
|
||||
sqlx::query("UPDATE tenant SET churned_at = ? WHERE pubkey = ?")
|
||||
.bind(churned_at)
|
||||
.bind(pubkey)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn clear_tenant_nwc_error_tx(tx: &mut Transaction<'_, Sqlite>, pubkey: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE tenant SET nwc_error = NULL WHERE pubkey = ?")
|
||||
.bind(pubkey)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn clear_tenant_stripe_error_tx(tx: &mut Transaction<'_, Sqlite>, pubkey: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE tenant SET stripe_error = NULL WHERE pubkey = ?")
|
||||
.bind(pubkey)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record the Stripe PaymentIntent that paid an invoice. Keyed by the Stripe
|
||||
/// PaymentIntent id, so it's idempotent.
|
||||
async fn insert_intent_tx(
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
intent_id: &str,
|
||||
invoice_id: &str,
|
||||
) -> Result<()> {
|
||||
let created_at = chrono::Utc::now().timestamp();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO intent (id, invoice_id, created_at)
|
||||
VALUES (?, ?, ?) ON CONFLICT(id) DO NOTHING",
|
||||
)
|
||||
.bind(intent_id)
|
||||
.bind(invoice_id)
|
||||
.bind(created_at)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+22
-10
@@ -37,13 +37,26 @@ pub struct Plan {
|
||||
pub struct Tenant {
|
||||
pub pubkey: String,
|
||||
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.
|
||||
pub nwc_error: Option<String>,
|
||||
/// Last Stripe auto-payment error, with the same semantics as `nwc_error`.
|
||||
pub stripe_error: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub billing_anchor: Option<i64>,
|
||||
pub stripe_customer_id: String,
|
||||
/// The tenant's saved Stripe payment method, or `None` if they have not set
|
||||
/// up a card yet. Set when the tenant adds a card via the Stripe portal.
|
||||
pub stripe_payment_method_id: Option<String>,
|
||||
/// `period_start` of the most recent period this tenant was renewed for, or
|
||||
/// `None` if never renewed. The per-period renewal idempotency marker.
|
||||
pub renewed_at: Option<i64>,
|
||||
/// When the tenant was churned because an invoice went unpaid past the grace
|
||||
/// period; its relays are delinquent while this is set. Cleared when billing
|
||||
/// is re-activated (the tenant has new billable activity), at which point the
|
||||
/// then-open invoices are voided rather than collected. `None` in good standing.
|
||||
pub churned_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
@@ -103,15 +116,22 @@ impl Default for Relay {
|
||||
}
|
||||
}
|
||||
|
||||
/// A tenant's bill for one period. Its lifecycle is recorded as timestamps
|
||||
/// rather than a status column: open while both `paid_at` and `voided_at` are
|
||||
/// null, paid once `paid_at` is set, and void once `voided_at` is set (e.g. a
|
||||
/// balance forgiven when the tenant churns).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Invoice {
|
||||
pub id: String,
|
||||
pub tenant_pubkey: String,
|
||||
pub status: 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 updated_at: i64,
|
||||
pub paid_at: Option<i64>,
|
||||
pub voided_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
@@ -139,11 +159,3 @@ pub struct Bolt11 {
|
||||
pub expires_at: i64,
|
||||
pub settled_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // mirrors the `intent` table; rows record paid Stripe PaymentIntents but aren't read back into this struct yet
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Intent {
|
||||
pub id: String,
|
||||
pub invoice_id: String,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
+14
-10
@@ -1,6 +1,6 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
use crate::models::{Activity, Bolt11, Invoice, InvoiceItem, Plan, Relay, Tenant};
|
||||
use crate::models::{Activity, Bolt11, Invoice, Plan, Relay, Tenant};
|
||||
use crate::db::pool;
|
||||
|
||||
fn select_tenant(tail: &str) -> String {
|
||||
@@ -125,7 +125,7 @@ pub async fn get_invoice(invoice_id: &str) -> Result<Option<Invoice>> {
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn list_invoices_for_tenant(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
|
||||
pub async fn list_invoices(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
|
||||
Ok(sqlx::query_as::<_, Invoice>(
|
||||
"SELECT * FROM invoice WHERE tenant_pubkey = ? ORDER BY created_at DESC",
|
||||
)
|
||||
@@ -134,7 +134,7 @@ pub async fn list_invoices_for_tenant(tenant_pubkey: &str) -> Result<Vec<Invoice
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_latest_invoice_for_tenant(tenant_pubkey: &str) -> Result<Option<Invoice>> {
|
||||
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",
|
||||
)
|
||||
@@ -143,13 +143,17 @@ pub async fn get_latest_invoice_for_tenant(tenant_pubkey: &str) -> Result<Option
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_invoice_items_for_invoice(invoice_id: &str) -> Result<Vec<InvoiceItem>> {
|
||||
Ok(
|
||||
sqlx::query_as::<_, InvoiceItem>("SELECT * FROM invoice_item WHERE invoice_id = ?")
|
||||
.bind(invoice_id)
|
||||
.fetch_all(pool())
|
||||
.await?,
|
||||
/// A tenant's open invoices — neither paid nor voided — oldest first. Dunning
|
||||
/// retries each and treats the oldest one's `created_at` as the grace-period start.
|
||||
pub async fn list_open_invoices(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
|
||||
Ok(sqlx::query_as::<_, Invoice>(
|
||||
"SELECT * FROM invoice
|
||||
WHERE tenant_pubkey = ? AND paid_at IS NULL AND voided_at IS NULL
|
||||
ORDER BY created_at ASC",
|
||||
)
|
||||
.bind(tenant_pubkey)
|
||||
.fetch_all(pool())
|
||||
.await?)
|
||||
}
|
||||
|
||||
// --- Bolt11 ---
|
||||
@@ -176,7 +180,7 @@ pub async fn get_bolt11_for_invoice(invoice_id: &str) -> Result<Option<Bolt11>>
|
||||
/// activity-type filter and the `billed_at IS NULL` guard live here so the
|
||||
/// caller reconciles off a precise marker rather than a timestamp watermark.
|
||||
/// Ordered oldest-first so line items and proration apply in event order.
|
||||
pub async fn list_billable_activity_for_tenant(tenant_pubkey: &str) -> Result<Vec<Activity>> {
|
||||
pub async fn list_billable_activity(tenant_pubkey: &str) -> Result<Vec<Activity>> {
|
||||
Ok(sqlx::query_as::<_, Activity>(&select_activity(
|
||||
"WHERE tenant_pubkey = ?
|
||||
AND billed_at IS NULL
|
||||
|
||||
@@ -21,7 +21,7 @@ pub async fn get_tenant_latest_invoice(
|
||||
|
||||
api.billing.reconcile_subscription(&tenant).await.map_err(internal)?;
|
||||
|
||||
let invoice = query::get_latest_invoice_for_tenant(&pubkey).await.map_err(internal)?;
|
||||
let invoice = query::get_latest_invoice(&pubkey).await.map_err(internal)?;
|
||||
|
||||
ok(invoice)
|
||||
}
|
||||
@@ -57,7 +57,7 @@ pub async fn get_invoice_bolt11(
|
||||
|
||||
let bolt11 = api
|
||||
.billing
|
||||
.ensure_and_reconcile_bolt11(&invoice_id)
|
||||
.ensure_and_reconcile_bolt11(&invoice)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
|
||||
@@ -17,9 +17,14 @@ pub struct TenantResponse {
|
||||
pub pubkey: String,
|
||||
pub nwc_is_set: bool,
|
||||
pub nwc_error: Option<String>,
|
||||
pub stripe_error: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub billing_anchor: Option<i64>,
|
||||
pub stripe_customer_id: String,
|
||||
pub stripe_payment_method_id: Option<String>,
|
||||
/// Set when billing has churned the tenant; the UI uses it to warn that the
|
||||
/// account is delinquent until billing is re-activated.
|
||||
pub churned_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl From<Tenant> for TenantResponse {
|
||||
@@ -28,9 +33,12 @@ impl From<Tenant> for TenantResponse {
|
||||
nwc_is_set: !t.nwc_url.is_empty(),
|
||||
pubkey: t.pubkey,
|
||||
nwc_error: t.nwc_error,
|
||||
stripe_error: t.stripe_error,
|
||||
created_at: t.created_at,
|
||||
billing_anchor: t.billing_anchor,
|
||||
stripe_customer_id: t.stripe_customer_id,
|
||||
stripe_payment_method_id: t.stripe_payment_method_id,
|
||||
churned_at: t.churned_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,7 +157,7 @@ pub async fn list_tenant_invoices(
|
||||
) -> ApiResult {
|
||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||
|
||||
let invoices = query::list_invoices_for_tenant(&pubkey)
|
||||
let invoices = query::list_invoices(&pubkey)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { listTenantInvoices, type Invoice } from "@/lib/api"
|
||||
import { invoiceStatus, listTenantInvoices, type Invoice } from "@/lib/api"
|
||||
import { account, eventStore, identity } from "@/lib/state"
|
||||
import serverIcon from "@/assets/server.svg"
|
||||
import Modal from "@/components/Modal"
|
||||
@@ -45,13 +45,13 @@ export default function AppShell(props: { children?: any }) {
|
||||
|
||||
createEffect(async () => {
|
||||
const t = tenant()
|
||||
if (!t?.past_due_at) {
|
||||
if (!t?.churned_at) {
|
||||
setPastDueInvoice(undefined)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const invoices = await listTenantInvoices(t.pubkey)
|
||||
const openInvoice = invoices.find(inv => inv.status === "open")
|
||||
const openInvoice = invoices.find(inv => invoiceStatus(inv) === "open")
|
||||
setPastDueInvoice(openInvoice)
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -158,9 +158,9 @@ export default function AppShell(props: { children?: any }) {
|
||||
</aside>
|
||||
|
||||
<div class="md:pl-[260px] min-h-screen pb-20 md:pb-0">
|
||||
<Show when={tenant()?.past_due_at}>
|
||||
<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 has an overdue balance.</span>
|
||||
<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"
|
||||
|
||||
@@ -9,7 +9,7 @@ import { plans } from "@/lib/state"
|
||||
type PayStatus = "idle" | "loading" | "success" | "error"
|
||||
type Bolt11Status = "idle" | "loading" | "ready" | "error"
|
||||
|
||||
type PaymentInvoice = Pick<Invoice, "id" | "amount_due"> &
|
||||
type PaymentInvoice = Pick<Invoice, "id" | "amount"> &
|
||||
Partial<Pick<Invoice, "period_start" | "period_end">>
|
||||
|
||||
type PaymentDialogProps = {
|
||||
@@ -68,7 +68,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
setPayError("")
|
||||
try {
|
||||
const invoice = await getInvoice(props.invoice.id)
|
||||
if (invoice.status === "paid") {
|
||||
if (invoice.paid_at != null) {
|
||||
setPayStatus("success")
|
||||
} else {
|
||||
setPayStatus("error")
|
||||
@@ -90,7 +90,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
const amountLabel = () => `$${(props.invoice.amount_due / 100).toFixed(2)}`
|
||||
const amountLabel = () => `$${(props.invoice.amount / 100).toFixed(2)}`
|
||||
|
||||
const periodLabel = () => {
|
||||
const { period_start, period_end } = props.invoice
|
||||
|
||||
+17
-8
@@ -98,19 +98,30 @@ export type Tenant = {
|
||||
nwc_is_set: boolean
|
||||
created_at: number
|
||||
stripe_customer_id: string
|
||||
stripe_subscription_id: string | null
|
||||
past_due_at: number | null
|
||||
stripe_payment_method_id: string | null
|
||||
nwc_error: string | null
|
||||
stripe_error: string | null
|
||||
churned_at: number | null
|
||||
}
|
||||
|
||||
export type Invoice = {
|
||||
id: string
|
||||
customer: string
|
||||
status: string
|
||||
amount_due: number
|
||||
currency: string
|
||||
tenant_pubkey: string
|
||||
amount: number
|
||||
period_start: number
|
||||
period_end: number
|
||||
created_at: number
|
||||
paid_at: number | null
|
||||
voided_at: number | null
|
||||
}
|
||||
|
||||
// The backend models an invoice's lifecycle as timestamps rather than a status
|
||||
// field, so derive the display status from them: paid once paid_at is set, void
|
||||
// once voided_at is set, otherwise still open.
|
||||
export function invoiceStatus(invoice: Pick<Invoice, "paid_at" | "voided_at">): "open" | "paid" | "void" {
|
||||
if (invoice.paid_at != null) return "paid"
|
||||
if (invoice.voided_at != null) return "void"
|
||||
return "open"
|
||||
}
|
||||
|
||||
export type Activity = {
|
||||
@@ -142,8 +153,6 @@ export async function makeAuth(): Promise<string | undefined> {
|
||||
kind: 27235,
|
||||
content: "",
|
||||
created_at: Math.floor(now / 1000),
|
||||
// Intentional session-style auth: sign the API base URL once, then reuse
|
||||
// the header briefly to avoid prompting the signer on every request.
|
||||
tags: [["u", API_URL]],
|
||||
})
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
reactivateRelay,
|
||||
getRelay,
|
||||
getTenant,
|
||||
invoiceStatus,
|
||||
listRelayActivity,
|
||||
listRelays,
|
||||
listTenantInvoices,
|
||||
@@ -135,13 +136,13 @@ export const reactivateRelayById = (id: string) => reactivateRelay(id)
|
||||
|
||||
export async function tenantNeedsPaymentSetup(): Promise<boolean> {
|
||||
const tenant = await getTenant(account()!.pubkey)
|
||||
return !tenant.nwc_is_set && !tenant.stripe_subscription_id
|
||||
return !tenant.nwc_is_set && !tenant.stripe_payment_method_id
|
||||
}
|
||||
|
||||
export async function getLatestOpenInvoice(): Promise<Invoice | null> {
|
||||
const invoices = await listTenantInvoices(account()!.pubkey)
|
||||
const open = invoices
|
||||
.filter(inv => inv.status === "open" && inv.amount_due > 0)
|
||||
.filter(inv => invoiceStatus(inv) === "open" && inv.amount > 0)
|
||||
.sort((a, b) => b.period_start - a.period_start)
|
||||
return open[0] ?? null
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import LoadingState from "@/components/LoadingState"
|
||||
import PaymentDialog from "@/components/PaymentDialog"
|
||||
import useMinLoading from "@/components/useMinLoading"
|
||||
import { updateActiveTenant, useTenant } from "@/lib/hooks"
|
||||
import { createPortalSession, getInvoice, listTenantInvoices, type Invoice } from "@/lib/api"
|
||||
import { createPortalSession, getInvoice, invoiceStatus, listTenantInvoices, type Invoice } from "@/lib/api"
|
||||
import { account } from "@/lib/state"
|
||||
|
||||
export default function Account() {
|
||||
@@ -75,11 +75,9 @@ export default function Account() {
|
||||
}
|
||||
|
||||
const invoiceStatusStyles: Record<string, string> = {
|
||||
draft: "bg-gray-100 text-gray-500 border-gray-200",
|
||||
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",
|
||||
uncollectible: "bg-red-50 text-red-700 border-red-200",
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -142,8 +140,16 @@ 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">{tenant()!.nwc_error}</p>
|
||||
<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>
|
||||
@@ -160,8 +166,9 @@ export default function Account() {
|
||||
<ul class="space-y-3">
|
||||
<For each={invoices()}>
|
||||
{(invoice) => {
|
||||
const isOpen = () => invoice.status === "open"
|
||||
const statusStyle = () => invoiceStatusStyles[invoice.status] ?? "bg-gray-100 text-gray-500 border-gray-200"
|
||||
const status = () => invoiceStatus(invoice)
|
||||
const isOpen = () => status() === "open"
|
||||
const statusStyle = () => invoiceStatusStyles[status()] ?? "bg-gray-100 text-gray-500 border-gray-200"
|
||||
const periodLabel = () => {
|
||||
const start = new Date(invoice.period_start * 1000)
|
||||
const end = new Date(invoice.period_end * 1000)
|
||||
@@ -177,7 +184,7 @@ export default function Account() {
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<span class="font-medium text-gray-900">
|
||||
${(invoice.amount_due / 100).toFixed(2)}
|
||||
${(invoice.amount / 100).toFixed(2)}
|
||||
</span>
|
||||
<Show when={invoice.period_start && invoice.period_end}>
|
||||
<p class="text-xs text-gray-500 mt-0.5">{periodLabel()}</p>
|
||||
@@ -188,7 +195,7 @@ export default function Account() {
|
||||
<span class="text-xs text-blue-600 font-medium">Pay now</span>
|
||||
</Show>
|
||||
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${statusStyle()}`}>
|
||||
{invoice.status}
|
||||
{status()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,8 +14,8 @@ export default function AdminTenantDetail() {
|
||||
const [relays] = useAdminTenantRelays(tenantId)
|
||||
const loading = useMinLoading(() => tenant.loading || relays.loading)
|
||||
|
||||
const pastDueLabel = () => {
|
||||
const ts = tenant()?.past_due_at
|
||||
const churnedLabel = () => {
|
||||
const ts = tenant()?.churned_at
|
||||
if (!ts) return null
|
||||
return new Date(ts * 1000).toLocaleString()
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export default function AdminTenantDetail() {
|
||||
<dl class="grid gap-y-3 text-sm">
|
||||
<div class="flex gap-2">
|
||||
<dt class="text-gray-500">Status:</dt>
|
||||
<dd class="font-medium uppercase tracking-wide">{t().past_due_at ? "past due" : "active"}</dd>
|
||||
<dd class="font-medium uppercase tracking-wide">{t().churned_at ? "delinquent" : "active"}</dd>
|
||||
</div>
|
||||
<Show when={t().stripe_customer_id}>
|
||||
<div class="flex gap-2">
|
||||
@@ -42,10 +42,10 @@ export default function AdminTenantDetail() {
|
||||
<dd class="font-mono text-xs">{t().stripe_customer_id}</dd>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={pastDueLabel()}>
|
||||
<Show when={churnedLabel()}>
|
||||
<div class="flex gap-2">
|
||||
<dt class="text-gray-500">Past Due Since:</dt>
|
||||
<dd class="text-red-600 font-medium">{pastDueLabel()}</dd>
|
||||
<dt class="text-gray-500">Delinquent Since:</dt>
|
||||
<dd class="text-red-600 font-medium">{churnedLabel()}</dd>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={t().nwc_error}>
|
||||
@@ -54,6 +54,12 @@ export default function AdminTenantDetail() {
|
||||
<dd class="text-red-600">{t().nwc_error}</dd>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={t().stripe_error}>
|
||||
<div class="flex gap-2">
|
||||
<dt class="text-gray-500">Stripe Error:</dt>
|
||||
<dd class="text-red-600">{t().stripe_error}</dd>
|
||||
</div>
|
||||
</Show>
|
||||
</dl>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function RelayDetail() {
|
||||
if (!isPaidRelay()) return false
|
||||
const t = tenant()
|
||||
if (!t) return false
|
||||
return !t.nwc_is_set
|
||||
return !t.nwc_is_set && !t.stripe_payment_method_id
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user