forked from coracle/caravel
Add dunning
This commit is contained in:
@@ -1,5 +1,3 @@
|
|||||||
ref
|
ref
|
||||||
target
|
target
|
||||||
.agents
|
|
||||||
.playwright-cli
|
|
||||||
node_modules
|
node_modules
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ CREATE TABLE IF NOT EXISTS tenant (
|
|||||||
pubkey TEXT PRIMARY KEY,
|
pubkey TEXT PRIMARY KEY,
|
||||||
nwc_url TEXT NOT NULL DEFAULT '',
|
nwc_url TEXT NOT NULL DEFAULT '',
|
||||||
nwc_error TEXT,
|
nwc_error TEXT,
|
||||||
|
stripe_error TEXT,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
billing_anchor INTEGER,
|
billing_anchor INTEGER,
|
||||||
stripe_customer_id TEXT NOT NULL,
|
stripe_customer_id TEXT NOT NULL,
|
||||||
renewed_at INTEGER
|
renewed_at INTEGER,
|
||||||
|
churned_at INTEGER
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS activity (
|
CREATE TABLE IF NOT EXISTS activity (
|
||||||
@@ -45,12 +47,12 @@ CREATE TABLE IF NOT EXISTS relay (
|
|||||||
CREATE TABLE IF NOT EXISTS invoice (
|
CREATE TABLE IF NOT EXISTS invoice (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
tenant_pubkey TEXT NOT NULL,
|
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')),
|
method TEXT CHECK (method IS NULL OR method IN ('nwc','stripe','oob')),
|
||||||
period_start INTEGER NOT NULL,
|
period_start INTEGER NOT NULL,
|
||||||
period_end INTEGER NOT NULL,
|
period_end INTEGER NOT NULL,
|
||||||
created_at 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)
|
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -96,6 +98,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);
|
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_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;
|
CREATE INDEX IF NOT EXISTS idx_invoice_item_outstanding ON invoice_item (tenant_pubkey) WHERE invoice_id IS NULL;
|
||||||
|
|||||||
+113
-33
@@ -4,12 +4,21 @@ use std::time::Duration;
|
|||||||
use crate::bitcoin;
|
use crate::bitcoin;
|
||||||
use crate::command;
|
use crate::command;
|
||||||
use crate::env;
|
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, RELAY_STATUS_DELINQUENT, Snapshot,
|
||||||
|
Tenant,
|
||||||
|
};
|
||||||
use crate::query;
|
use crate::query;
|
||||||
use crate::robot::Robot;
|
use crate::robot::Robot;
|
||||||
use crate::stripe::Stripe;
|
use crate::stripe::Stripe;
|
||||||
use crate::wallet::Wallet;
|
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,
|
/// Owns subscription billing: it reconciles tenant activity into invoice items,
|
||||||
/// renews subscriptions each period, and collects payment (Lightning, then a
|
/// renews subscriptions each period, and collects payment (Lightning, then a
|
||||||
/// card on file, then a manual DM link).
|
/// card on file, then a manual DM link).
|
||||||
@@ -63,16 +72,26 @@ impl Billing {
|
|||||||
|
|
||||||
// --- Reconciliation of activity/renewals ---
|
// --- Reconciliation of activity/renewals ---
|
||||||
|
|
||||||
/// Lists billable activity, setting the tenant's billing anchor to the first
|
/// Reconciles a tenant's billing: re-activates them if a churned tenant has
|
||||||
/// activity in the process. Generates an invoice for the current period if due
|
/// re-engaged, folds billable activity into line items (setting the billing
|
||||||
/// for renewal or any billable activities have occurred. Attempts payment if
|
/// anchor on the first), renews the current period if due, claims outstanding
|
||||||
/// an invoice is generated.
|
/// 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<()> {
|
pub async fn reconcile_subscription(&self, tenant: &Tenant) -> Result<()> {
|
||||||
let mut tenant = tenant.clone();
|
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() {
|
||||||
|
self.reactivate_tenant(&tenant).await?;
|
||||||
|
tenant.churned_at = None;
|
||||||
|
}
|
||||||
|
|
||||||
// Reconcile all activity, setting the tenant's billing anchor on the first
|
// Reconcile all activity, setting the tenant's billing anchor on the first
|
||||||
// positive-balance line item if not already set.
|
// activity if not already set.
|
||||||
for activity in query::list_billable_activity_for_tenant(&tenant.pubkey).await? {
|
for activity in activities {
|
||||||
if tenant.billing_anchor.is_none() {
|
if tenant.billing_anchor.is_none() {
|
||||||
tenant.billing_anchor = Some(activity.created_at);
|
tenant.billing_anchor = Some(activity.created_at);
|
||||||
command::set_tenant_billing_anchor(&tenant).await?;
|
command::set_tenant_billing_anchor(&tenant).await?;
|
||||||
@@ -81,20 +100,19 @@ impl Billing {
|
|||||||
self.reconcile_activity(&tenant, &activity).await?;
|
self.reconcile_activity(&tenant, &activity).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the tenant has no billing anchor, they have nothing to bill
|
// If the tenant has a billing anchor, renew the current period if due and
|
||||||
let Some(period) = BillingPeriod::current(&tenant) else {
|
// claim any outstanding items onto an invoice.
|
||||||
return Ok(());
|
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.
|
command::create_invoice(&tenant, &period).await?;
|
||||||
if tenant.renewed_at.is_none_or(|at| at < period.start) {
|
|
||||||
self.reconcile_renewal(&tenant, &period).await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the invoice, but only if non-zero and attempt payment
|
// Retry payment on every open invoice (this also pays one just created),
|
||||||
if let Some(invoice) = command::create_invoice(&tenant, &period).await? {
|
// churning the tenant if the oldest has aged past the grace period.
|
||||||
self.attempt_payment(&tenant, &invoice).await?;
|
self.collect_open_invoices(&tenant).await?;
|
||||||
};
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -127,9 +145,10 @@ impl Billing {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A prorated charge (or credit, with `sign` = -1) for the relay's current
|
/// A prorated charge (or credit, with `sign` = -1) for the plan recorded on
|
||||||
/// plan, covering the fraction of the period remaining at the activity.
|
/// the activity's snapshot — the plan as of the activity, not the relay's
|
||||||
/// `None` for a missing relay or a free plan.
|
/// current plan — covering the fraction of the period remaining at the
|
||||||
|
/// activity. `None` for a free plan.
|
||||||
async fn make_prorated_item(
|
async fn make_prorated_item(
|
||||||
&self,
|
&self,
|
||||||
tenant: &Tenant,
|
tenant: &Tenant,
|
||||||
@@ -137,10 +156,8 @@ impl Billing {
|
|||||||
sign: i64,
|
sign: i64,
|
||||||
description: &str,
|
description: &str,
|
||||||
) -> Result<Option<InvoiceItem>> {
|
) -> Result<Option<InvoiceItem>> {
|
||||||
let Some(relay) = query::get_relay(&activity.resource_id).await? else {
|
let Snapshot::Relay { plan: plan_id, .. } = &*activity.snapshot;
|
||||||
return Err(anyhow!("activity resource was not a valid relay"));
|
let plan = query::get_plan(plan_id)?;
|
||||||
};
|
|
||||||
let plan = query::get_plan(&relay.plan_id)?;
|
|
||||||
if plan.amount <= 0 {
|
if plan.amount <= 0 {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
@@ -216,7 +233,7 @@ impl Billing {
|
|||||||
/// a relay created/activated *within* the period isn't active before the
|
/// a relay created/activated *within* the period isn't active before the
|
||||||
/// boundary, so it's covered by its own prorated charge instead.
|
/// boundary, so it's covered by its own prorated charge instead.
|
||||||
async fn reconcile_renewal(&self, tenant: &Tenant, period: &BillingPeriod) -> Result<()> {
|
async fn reconcile_renewal(&self, tenant: &Tenant, period: &BillingPeriod) -> Result<()> {
|
||||||
let relays = query::list_relays_for_tenant(&tenant.pubkey).await?;
|
let relays = query::list_relays(&tenant.pubkey).await?;
|
||||||
|
|
||||||
let mut line_items = Vec::new();
|
let mut line_items = Vec::new();
|
||||||
for relay in relays {
|
for relay in relays {
|
||||||
@@ -253,6 +270,10 @@ impl Billing {
|
|||||||
|
|
||||||
// --- Payments ---
|
// --- 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<()> {
|
pub async fn attempt_payment(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> {
|
||||||
let mut error_message: Option<String> = None;
|
let mut error_message: Option<String> = None;
|
||||||
|
|
||||||
@@ -260,7 +281,11 @@ impl Billing {
|
|||||||
if !tenant.nwc_url.is_empty() {
|
if !tenant.nwc_url.is_empty() {
|
||||||
match self.attempt_payment_using_nwc(tenant, invoice).await {
|
match self.attempt_payment_using_nwc(tenant, invoice).await {
|
||||||
Ok(()) => return Ok(()),
|
Ok(()) => return Ok(()),
|
||||||
Err(e) => error_message = Some(format!("{e}")),
|
Err(e) => {
|
||||||
|
let message = format!("{e}");
|
||||||
|
command::set_tenant_nwc_error(&tenant.pubkey, &message).await?;
|
||||||
|
error_message = Some(message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +298,11 @@ impl Billing {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(()) => return Ok(()),
|
Ok(()) => return Ok(()),
|
||||||
Err(e) => error_message = Some(format!("{e}")),
|
Err(e) => {
|
||||||
|
let message = format!("{e}");
|
||||||
|
command::set_tenant_stripe_error(&tenant.pubkey, &message).await?;
|
||||||
|
error_message = error_message.or_else(|| Some(message));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,6 +364,7 @@ impl Billing {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
command::insert_intent(&intent_id, &invoice.id).await?;
|
command::insert_intent(&intent_id, &invoice.id).await?;
|
||||||
|
command::clear_tenant_stripe_error(&tenant.pubkey).await?;
|
||||||
command::mark_invoice_paid(&invoice.id, "stripe").await
|
command::mark_invoice_paid(&invoice.id, "stripe").await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,6 +388,61 @@ impl Billing {
|
|||||||
self.robot.send_dm(&tenant.pubkey, &dm_message).await
|
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() {
|
||||||
|
self.churn_tenant(tenant, now).await?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for invoice in &open {
|
||||||
|
self.attempt_payment(tenant, invoice).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Churn a tenant whose grace period has elapsed: record the churn, mark every
|
||||||
|
/// active relay delinquent, and void the unpaid invoices (we've given up
|
||||||
|
/// collecting them — re-activation never requires they be paid).
|
||||||
|
async fn churn_tenant(&self, tenant: &Tenant, now: i64) -> Result<()> {
|
||||||
|
command::set_tenant_churned_at(&tenant.pubkey, Some(now)).await?;
|
||||||
|
|
||||||
|
for relay in query::list_relays(&tenant.pubkey).await? {
|
||||||
|
if relay.status == RELAY_STATUS_ACTIVE {
|
||||||
|
command::mark_relay_delinquent(&relay).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
command::void_open_invoices(&tenant.pubkey).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-activate a churned tenant: clear the churn marker and restore every
|
||||||
|
/// delinquent relay to active. Any still-open invoices are voided so old debt
|
||||||
|
/// is never a precondition for coming back.
|
||||||
|
async fn reactivate_tenant(&self, tenant: &Tenant) -> Result<()> {
|
||||||
|
command::set_tenant_churned_at(&tenant.pubkey, None).await?;
|
||||||
|
|
||||||
|
for relay in query::list_relays(&tenant.pubkey).await? {
|
||||||
|
if relay.status == RELAY_STATUS_DELINQUENT {
|
||||||
|
command::unmark_relay_delinquent(&relay).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
command::void_open_invoices(&tenant.pubkey).await
|
||||||
|
}
|
||||||
|
|
||||||
// --- Invoice utils ---
|
// --- Invoice utils ---
|
||||||
|
|
||||||
pub async fn get_invoice_amount(&self, invoice_id: &str) -> Result<i64> {
|
pub async fn get_invoice_amount(&self, invoice_id: &str) -> Result<i64> {
|
||||||
@@ -407,11 +492,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
|
/// One tenant's monthly billing period containing some timestamp, anchored at
|
||||||
/// the tenant's `billing_anchor`. Half-open `[start, end)` so a moment at
|
/// the tenant's `billing_anchor`. Half-open `[start, end)` so a moment at
|
||||||
/// exactly `end` belongs to the next period.
|
/// exactly `end` belongs to the next period.
|
||||||
|
|||||||
+65
-7
@@ -43,6 +43,15 @@ pub async fn set_tenant_billing_anchor(tenant: &Tenant) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn set_tenant_nwc_error(pubkey: &str, error: &str) -> Result<()> {
|
||||||
|
sqlx::query("UPDATE tenant SET nwc_error = ? WHERE pubkey = ?")
|
||||||
|
.bind(error)
|
||||||
|
.bind(pubkey)
|
||||||
|
.execute(pool())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn clear_tenant_nwc_error(pubkey: &str) -> Result<()> {
|
pub async fn clear_tenant_nwc_error(pubkey: &str) -> Result<()> {
|
||||||
sqlx::query("UPDATE tenant SET nwc_error = NULL WHERE pubkey = ?")
|
sqlx::query("UPDATE tenant SET nwc_error = NULL WHERE pubkey = ?")
|
||||||
.bind(pubkey)
|
.bind(pubkey)
|
||||||
@@ -51,6 +60,34 @@ pub async fn clear_tenant_nwc_error(pubkey: &str) -> Result<()> {
|
|||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn clear_tenant_stripe_error(pubkey: &str) -> Result<()> {
|
||||||
|
sqlx::query("UPDATE tenant SET stripe_error = NULL WHERE pubkey = ?")
|
||||||
|
.bind(pubkey)
|
||||||
|
.execute(pool())
|
||||||
|
.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.
|
||||||
|
pub async fn set_tenant_churned_at(pubkey: &str, churned_at: Option<i64>) -> Result<()> {
|
||||||
|
sqlx::query("UPDATE tenant SET churned_at = ? WHERE pubkey = ?")
|
||||||
|
.bind(churned_at)
|
||||||
|
.bind(pubkey)
|
||||||
|
.execute(pool())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// --- Relays ---
|
// --- Relays ---
|
||||||
|
|
||||||
pub async fn create_relay(relay: &Relay) -> Result<()> {
|
pub async fn create_relay(relay: &Relay) -> Result<()> {
|
||||||
@@ -140,11 +177,17 @@ pub async fn deactivate_relay(relay: &Relay) -> Result<()> {
|
|||||||
set_relay_status(relay, RELAY_STATUS_INACTIVE, "deactivate_relay").await
|
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<()> {
|
pub async fn mark_relay_delinquent(relay: &Relay) -> Result<()> {
|
||||||
set_relay_status(relay, RELAY_STATUS_DELINQUENT, "mark_relay_delinquent").await
|
set_relay_status(relay, RELAY_STATUS_DELINQUENT, "mark_relay_delinquent").await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restore a delinquent relay to active when billing is re-activated. Unlike
|
||||||
|
/// `activate_relay` this records a non-billable activity, so resuming service
|
||||||
|
/// after churn doesn't levy a fresh prorated charge.
|
||||||
|
pub async fn unmark_relay_delinquent(relay: &Relay) -> Result<()> {
|
||||||
|
set_relay_status(relay, RELAY_STATUS_ACTIVE, "unmark_relay_delinquent").await
|
||||||
|
}
|
||||||
|
|
||||||
async fn set_relay_status(relay: &Relay, status: &str, activity_type: &str) -> Result<()> {
|
async fn set_relay_status(relay: &Relay, status: &str, activity_type: &str) -> Result<()> {
|
||||||
let activity = with_tx(async |tx| {
|
let activity = with_tx(async |tx| {
|
||||||
sqlx::query("UPDATE relay SET status = ?, synced = 0 WHERE id = ?")
|
sqlx::query("UPDATE relay SET status = ?, synced = 0 WHERE id = ?")
|
||||||
@@ -306,17 +349,33 @@ pub async fn create_invoice(tenant: &Tenant, period: &BillingPeriod) -> Result<O
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn mark_invoice_paid(invoice_id: &str, method: &str) -> Result<()> {
|
pub async fn mark_invoice_paid(invoice_id: &str, method: &str) -> Result<()> {
|
||||||
let updated_at = chrono::Utc::now().timestamp();
|
let paid_at = chrono::Utc::now().timestamp();
|
||||||
|
|
||||||
sqlx::query("UPDATE invoice SET status = 'paid', method = ?, updated_at = ? WHERE id = ?")
|
sqlx::query("UPDATE invoice SET method = ?, paid_at = ? WHERE id = ?")
|
||||||
.bind(method)
|
.bind(method)
|
||||||
.bind(updated_at)
|
.bind(paid_at)
|
||||||
.bind(invoice_id)
|
.bind(invoice_id)
|
||||||
.execute(pool())
|
.execute(pool())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
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.
|
||||||
|
pub async fn void_open_invoices(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(pool())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// --- Bolt11 records ---
|
// --- Bolt11 records ---
|
||||||
|
|
||||||
pub async fn insert_bolt11(
|
pub async fn insert_bolt11(
|
||||||
@@ -430,15 +489,14 @@ async fn insert_invoice_tx(
|
|||||||
let invoice_id = uuid::Uuid::new_v4().to_string();
|
let invoice_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
Ok(sqlx::query_as::<_, Invoice>(
|
Ok(sqlx::query_as::<_, Invoice>(
|
||||||
"INSERT INTO invoice (id, tenant_pubkey, status, period_start, period_end, created_at, updated_at)
|
"INSERT INTO invoice (id, tenant_pubkey, period_start, period_end, created_at)
|
||||||
VALUES (?, ?, 'open', ?, ?, ?, ?) RETURNING *",
|
VALUES (?, ?, ?, ?, ?) RETURNING *",
|
||||||
)
|
)
|
||||||
.bind(invoice_id)
|
.bind(invoice_id)
|
||||||
.bind(&tenant.pubkey)
|
.bind(&tenant.pubkey)
|
||||||
.bind(&period.start)
|
.bind(&period.start)
|
||||||
.bind(period.end)
|
.bind(period.end)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(now)
|
|
||||||
.fetch_one(&mut **tx)
|
.fetch_one(&mut **tx)
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-3
@@ -37,13 +37,23 @@ pub struct Plan {
|
|||||||
pub struct Tenant {
|
pub struct Tenant {
|
||||||
pub pubkey: String,
|
pub pubkey: String,
|
||||||
pub nwc_url: 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>,
|
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 created_at: i64,
|
||||||
pub billing_anchor: Option<i64>,
|
pub billing_anchor: Option<i64>,
|
||||||
pub stripe_customer_id: String,
|
pub stripe_customer_id: String,
|
||||||
/// `period_start` of the most recent period this tenant was renewed for, or
|
/// `period_start` of the most recent period this tenant was renewed for, or
|
||||||
/// `None` if never renewed. The per-period renewal idempotency marker.
|
/// `None` if never renewed. The per-period renewal idempotency marker.
|
||||||
pub renewed_at: Option<i64>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
@@ -103,15 +113,26 @@ 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct Invoice {
|
pub struct Invoice {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub tenant_pubkey: String,
|
pub tenant_pubkey: String,
|
||||||
pub status: String,
|
|
||||||
pub period_start: i64,
|
pub period_start: i64,
|
||||||
pub period_end: i64,
|
pub period_end: i64,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
pub updated_at: i64,
|
pub paid_at: Option<i64>,
|
||||||
|
pub voided_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Invoice {
|
||||||
|
/// An invoice is open — still collectible — until it's either paid or voided.
|
||||||
|
pub fn is_open(&self) -> bool {
|
||||||
|
self.paid_at.is_none() && self.voided_at.is_none()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
@@ -140,7 +161,8 @@ pub struct Bolt11 {
|
|||||||
pub settled_at: Option<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
|
/// A Stripe PaymentIntent that paid an invoice, mirrored for the audit trail and
|
||||||
|
/// read back to show the user how an invoice was settled.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct Intent {
|
pub struct Intent {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|||||||
+32
-5
@@ -1,6 +1,6 @@
|
|||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
|
|
||||||
use crate::models::{Activity, Bolt11, Invoice, InvoiceItem, Plan, Relay, Tenant};
|
use crate::models::{Activity, Bolt11, Intent, Invoice, InvoiceItem, Plan, Relay, Tenant};
|
||||||
use crate::db::pool;
|
use crate::db::pool;
|
||||||
|
|
||||||
fn select_tenant(tail: &str) -> String {
|
fn select_tenant(tail: &str) -> String {
|
||||||
@@ -84,7 +84,7 @@ pub async fn list_relays_pending_sync() -> Result<Vec<Relay>> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_relays_for_tenant(tenant_pubkey: &str) -> Result<Vec<Relay>> {
|
pub async fn list_relays(tenant_pubkey: &str) -> Result<Vec<Relay>> {
|
||||||
Ok(sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant_pubkey = ?"))
|
Ok(sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant_pubkey = ?"))
|
||||||
.bind(tenant_pubkey)
|
.bind(tenant_pubkey)
|
||||||
.fetch_all(pool())
|
.fetch_all(pool())
|
||||||
@@ -125,7 +125,7 @@ pub async fn get_invoice(invoice_id: &str) -> Result<Option<Invoice>> {
|
|||||||
.await?)
|
.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>(
|
Ok(sqlx::query_as::<_, Invoice>(
|
||||||
"SELECT * FROM invoice WHERE tenant_pubkey = ? ORDER BY created_at DESC",
|
"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?)
|
.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>(
|
Ok(sqlx::query_as::<_, Invoice>(
|
||||||
"SELECT * FROM invoice WHERE tenant_pubkey = ? ORDER BY created_at DESC LIMIT 1",
|
"SELECT * FROM invoice WHERE tenant_pubkey = ? ORDER BY created_at DESC LIMIT 1",
|
||||||
)
|
)
|
||||||
@@ -143,6 +143,19 @@ pub async fn get_latest_invoice_for_tenant(tenant_pubkey: &str) -> Result<Option
|
|||||||
.await?)
|
.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?)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_invoice_items_for_invoice(invoice_id: &str) -> Result<Vec<InvoiceItem>> {
|
pub async fn get_invoice_items_for_invoice(invoice_id: &str) -> Result<Vec<InvoiceItem>> {
|
||||||
Ok(
|
Ok(
|
||||||
sqlx::query_as::<_, InvoiceItem>("SELECT * FROM invoice_item WHERE invoice_id = ?")
|
sqlx::query_as::<_, InvoiceItem>("SELECT * FROM invoice_item WHERE invoice_id = ?")
|
||||||
@@ -152,6 +165,20 @@ pub async fn get_invoice_items_for_invoice(invoice_id: &str) -> Result<Vec<Invoi
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Intents ---
|
||||||
|
|
||||||
|
/// The Stripe PaymentIntents recorded against an invoice, newest first.
|
||||||
|
pub async fn list_intents_for_invoice(invoice_id: &str) -> Result<Vec<Intent>> {
|
||||||
|
Ok(
|
||||||
|
sqlx::query_as::<_, Intent>(
|
||||||
|
"SELECT * FROM intent WHERE invoice_id = ? ORDER BY created_at DESC",
|
||||||
|
)
|
||||||
|
.bind(invoice_id)
|
||||||
|
.fetch_all(pool())
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Bolt11 ---
|
// --- Bolt11 ---
|
||||||
|
|
||||||
pub async fn get_bolt11(bolt11_id: &str) -> Result<Option<Bolt11>> {
|
pub async fn get_bolt11(bolt11_id: &str) -> Result<Option<Bolt11>> {
|
||||||
@@ -176,7 +203,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
|
/// 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.
|
/// caller reconciles off a precise marker rather than a timestamp watermark.
|
||||||
/// Ordered oldest-first so line items and proration apply in event order.
|
/// 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(
|
Ok(sqlx::query_as::<_, Activity>(&select_activity(
|
||||||
"WHERE tenant_pubkey = ?
|
"WHERE tenant_pubkey = ?
|
||||||
AND billed_at IS NULL
|
AND billed_at IS NULL
|
||||||
|
|||||||
@@ -1,11 +1,53 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::api::{Api, AuthedPubkey};
|
use crate::api::{Api, AuthedPubkey};
|
||||||
|
use crate::models::{Intent, Invoice};
|
||||||
use crate::query;
|
use crate::query;
|
||||||
use crate::web::{ApiResult, internal, not_found, ok};
|
use crate::web::{ApiResult, internal, not_found, ok};
|
||||||
|
|
||||||
|
/// An invoice for the client, with its lifecycle flattened to a derived `status`
|
||||||
|
/// ("open" | "paid" | "void") alongside the underlying timestamps, plus the
|
||||||
|
/// Stripe PaymentIntents that settled it (empty unless requested).
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct InvoiceResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub tenant_pubkey: String,
|
||||||
|
pub status: String,
|
||||||
|
pub period_start: i64,
|
||||||
|
pub period_end: i64,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub paid_at: Option<i64>,
|
||||||
|
pub voided_at: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub intents: Vec<Intent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Invoice> for InvoiceResponse {
|
||||||
|
fn from(i: Invoice) -> Self {
|
||||||
|
let status = if i.is_open() {
|
||||||
|
"open"
|
||||||
|
} else if i.paid_at.is_some() {
|
||||||
|
"paid"
|
||||||
|
} else {
|
||||||
|
"void"
|
||||||
|
};
|
||||||
|
InvoiceResponse {
|
||||||
|
status: status.to_string(),
|
||||||
|
id: i.id,
|
||||||
|
tenant_pubkey: i.tenant_pubkey,
|
||||||
|
period_start: i.period_start,
|
||||||
|
period_end: i.period_end,
|
||||||
|
created_at: i.created_at,
|
||||||
|
paid_at: i.paid_at,
|
||||||
|
voided_at: i.voided_at,
|
||||||
|
intents: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The tenant's most recent invoice, after first materializing any outstanding
|
/// 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
|
/// 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
|
/// a change (e.g. creating a relay). Payment isn't attempted here; the caller
|
||||||
@@ -21,9 +63,9 @@ pub async fn get_tenant_latest_invoice(
|
|||||||
|
|
||||||
api.billing.reconcile_subscription(&tenant).await.map_err(internal)?;
|
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)
|
ok(invoice.map(InvoiceResponse::from))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_invoice(
|
pub async fn get_invoice(
|
||||||
@@ -38,7 +80,10 @@ pub async fn get_invoice(
|
|||||||
|
|
||||||
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
|
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
|
||||||
|
|
||||||
ok(invoice)
|
let mut response = InvoiceResponse::from(invoice);
|
||||||
|
response.intents = query::list_intents_for_invoice(&id).await.map_err(internal)?;
|
||||||
|
|
||||||
|
ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a payable Lightning invoice (bolt11) for an invoice, minting one if
|
/// Return a payable Lightning invoice (bolt11) for an invoice, minting one if
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::api::{Api, AuthedPubkey};
|
use crate::api::{Api, AuthedPubkey};
|
||||||
use crate::models::Tenant;
|
use crate::models::Tenant;
|
||||||
|
use crate::routes::invoices::InvoiceResponse;
|
||||||
use crate::web::{ApiResult, internal, map_unique_error, ok};
|
use crate::web::{ApiResult, internal, map_unique_error, ok};
|
||||||
use crate::{command, env, query};
|
use crate::{command, env, query};
|
||||||
|
|
||||||
@@ -17,9 +18,13 @@ pub struct TenantResponse {
|
|||||||
pub pubkey: String,
|
pub pubkey: String,
|
||||||
pub nwc_is_set: bool,
|
pub nwc_is_set: bool,
|
||||||
pub nwc_error: Option<String>,
|
pub nwc_error: Option<String>,
|
||||||
|
pub stripe_error: Option<String>,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
pub billing_anchor: Option<i64>,
|
pub billing_anchor: Option<i64>,
|
||||||
pub stripe_customer_id: String,
|
pub stripe_customer_id: 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 {
|
impl From<Tenant> for TenantResponse {
|
||||||
@@ -28,9 +33,11 @@ impl From<Tenant> for TenantResponse {
|
|||||||
nwc_is_set: !t.nwc_url.is_empty(),
|
nwc_is_set: !t.nwc_url.is_empty(),
|
||||||
pubkey: t.pubkey,
|
pubkey: t.pubkey,
|
||||||
nwc_error: t.nwc_error,
|
nwc_error: t.nwc_error,
|
||||||
|
stripe_error: t.stripe_error,
|
||||||
created_at: t.created_at,
|
created_at: t.created_at,
|
||||||
billing_anchor: t.billing_anchor,
|
billing_anchor: t.billing_anchor,
|
||||||
stripe_customer_id: t.stripe_customer_id,
|
stripe_customer_id: t.stripe_customer_id,
|
||||||
|
churned_at: t.churned_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,7 +142,7 @@ pub async fn list_tenant_relays(
|
|||||||
) -> ApiResult {
|
) -> ApiResult {
|
||||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||||
|
|
||||||
let relays = query::list_relays_for_tenant(&pubkey)
|
let relays = query::list_relays(&pubkey)
|
||||||
.await
|
.await
|
||||||
.map_err(internal)?;
|
.map_err(internal)?;
|
||||||
ok(relays)
|
ok(relays)
|
||||||
@@ -149,11 +156,14 @@ pub async fn list_tenant_invoices(
|
|||||||
) -> ApiResult {
|
) -> ApiResult {
|
||||||
api.require_admin_or_tenant(&auth, &pubkey)?;
|
api.require_admin_or_tenant(&auth, &pubkey)?;
|
||||||
|
|
||||||
let invoices = query::list_invoices_for_tenant(&pubkey)
|
let invoices = query::list_invoices(&pubkey)
|
||||||
.await
|
.await
|
||||||
.map_err(internal)?;
|
.map_err(internal)?;
|
||||||
|
|
||||||
ok(invoices)
|
ok(invoices
|
||||||
|
.into_iter()
|
||||||
|
.map(InvoiceResponse::from)
|
||||||
|
.collect::<Vec<_>>())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default function AppShell(props: { children?: any }) {
|
|||||||
|
|
||||||
createEffect(async () => {
|
createEffect(async () => {
|
||||||
const t = tenant()
|
const t = tenant()
|
||||||
if (!t?.past_due_at) {
|
if (!t?.churned_at) {
|
||||||
setPastDueInvoice(undefined)
|
setPastDueInvoice(undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -158,9 +158,9 @@ export default function AppShell(props: { children?: any }) {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="md:pl-[260px] min-h-screen pb-20 md:pb-0">
|
<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">
|
<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()}>
|
<Show when={pastDueInvoice()}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -98,9 +98,9 @@ export type Tenant = {
|
|||||||
nwc_is_set: boolean
|
nwc_is_set: boolean
|
||||||
created_at: number
|
created_at: number
|
||||||
stripe_customer_id: string
|
stripe_customer_id: string
|
||||||
stripe_subscription_id: string | null
|
|
||||||
past_due_at: number | null
|
|
||||||
nwc_error: string | null
|
nwc_error: string | null
|
||||||
|
stripe_error: string | null
|
||||||
|
churned_at: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Invoice = {
|
export type Invoice = {
|
||||||
@@ -142,8 +142,6 @@ export async function makeAuth(): Promise<string | undefined> {
|
|||||||
kind: 27235,
|
kind: 27235,
|
||||||
content: "",
|
content: "",
|
||||||
created_at: Math.floor(now / 1000),
|
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]],
|
tags: [["u", API_URL]],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export const reactivateRelayById = (id: string) => reactivateRelay(id)
|
|||||||
|
|
||||||
export async function tenantNeedsPaymentSetup(): Promise<boolean> {
|
export async function tenantNeedsPaymentSetup(): Promise<boolean> {
|
||||||
const tenant = await getTenant(account()!.pubkey)
|
const tenant = await getTenant(account()!.pubkey)
|
||||||
return !tenant.nwc_is_set && !tenant.stripe_subscription_id
|
return !tenant.nwc_is_set
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLatestOpenInvoice(): Promise<Invoice | null> {
|
export async function getLatestOpenInvoice(): Promise<Invoice | null> {
|
||||||
|
|||||||
@@ -142,8 +142,16 @@ export default function Account() {
|
|||||||
{saving() ? "Saving..." : "Save"}
|
{saving() ? "Saving..." : "Save"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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}>
|
<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>
|
||||||
<Show when={error()}>
|
<Show when={error()}>
|
||||||
<p class="mt-3 text-sm text-red-600">{error()}</p>
|
<p class="mt-3 text-sm text-red-600">{error()}</p>
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ export default function AdminTenantDetail() {
|
|||||||
const [relays] = useAdminTenantRelays(tenantId)
|
const [relays] = useAdminTenantRelays(tenantId)
|
||||||
const loading = useMinLoading(() => tenant.loading || relays.loading)
|
const loading = useMinLoading(() => tenant.loading || relays.loading)
|
||||||
|
|
||||||
const pastDueLabel = () => {
|
const churnedLabel = () => {
|
||||||
const ts = tenant()?.past_due_at
|
const ts = tenant()?.churned_at
|
||||||
if (!ts) return null
|
if (!ts) return null
|
||||||
return new Date(ts * 1000).toLocaleString()
|
return new Date(ts * 1000).toLocaleString()
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ export default function AdminTenantDetail() {
|
|||||||
<dl class="grid gap-y-3 text-sm">
|
<dl class="grid gap-y-3 text-sm">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<dt class="text-gray-500">Status:</dt>
|
<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>
|
</div>
|
||||||
<Show when={t().stripe_customer_id}>
|
<Show when={t().stripe_customer_id}>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -42,10 +42,10 @@ export default function AdminTenantDetail() {
|
|||||||
<dd class="font-mono text-xs">{t().stripe_customer_id}</dd>
|
<dd class="font-mono text-xs">{t().stripe_customer_id}</dd>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={pastDueLabel()}>
|
<Show when={churnedLabel()}>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<dt class="text-gray-500">Past Due Since:</dt>
|
<dt class="text-gray-500">Delinquent Since:</dt>
|
||||||
<dd class="text-red-600 font-medium">{pastDueLabel()}</dd>
|
<dd class="text-red-600 font-medium">{churnedLabel()}</dd>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={t().nwc_error}>
|
<Show when={t().nwc_error}>
|
||||||
@@ -54,6 +54,12 @@ export default function AdminTenantDetail() {
|
|||||||
<dd class="text-red-600">{t().nwc_error}</dd>
|
<dd class="text-red-600">{t().nwc_error}</dd>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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>
|
</dl>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
Reference in New Issue
Block a user