Improve transactionality, align invoice model with frontend
This commit is contained in:
@@ -49,6 +49,7 @@ CREATE TABLE IF NOT EXISTS invoice (
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
tenant_pubkey TEXT NOT NULL,
|
tenant_pubkey TEXT NOT NULL,
|
||||||
method TEXT CHECK (method IS NULL OR method IN ('nwc','stripe','oob')),
|
method TEXT CHECK (method IS NULL OR method IN ('nwc','stripe','oob')),
|
||||||
|
amount INTEGER NOT NULL,
|
||||||
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,
|
||||||
@@ -110,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 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_bolt11_invoice_created ON bolt11 (invoice_id, created_at);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_intent_invoice ON intent (invoice_id);
|
|
||||||
|
|||||||
+61
-96
@@ -5,8 +5,7 @@ use crate::bitcoin;
|
|||||||
use crate::command;
|
use crate::command;
|
||||||
use crate::env;
|
use crate::env;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
Activity, Bolt11, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, Snapshot,
|
Activity, Bolt11, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, Snapshot, Tenant,
|
||||||
Tenant,
|
|
||||||
};
|
};
|
||||||
use crate::query;
|
use crate::query;
|
||||||
use crate::robot::Robot;
|
use crate::robot::Robot;
|
||||||
@@ -85,7 +84,8 @@ impl Billing {
|
|||||||
// A churned tenant with fresh billable activity is using the service
|
// A churned tenant with fresh billable activity is using the service
|
||||||
// again: re-activate billing (and restore their relays) before billing it.
|
// again: re-activate billing (and restore their relays) before billing it.
|
||||||
if tenant.churned_at.is_some() && !activities.is_empty() {
|
if tenant.churned_at.is_some() && !activities.is_empty() {
|
||||||
self.reactivate_tenant(&tenant).await?;
|
let relays = query::list_relays_for_tenant(&tenant.pubkey).await?;
|
||||||
|
command::reactivate_tenant(&tenant.pubkey, &relays).await?;
|
||||||
tenant.churned_at = None;
|
tenant.churned_at = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,15 +281,20 @@ 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) => {
|
Err(e) => error_message = Some(format!("{e}")),
|
||||||
let message = format!("{e}");
|
|
||||||
command::set_tenant_nwc_error(&tenant.pubkey, &message).await?;
|
|
||||||
error_message = Some(message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) =
|
if let Some(payment_method) =
|
||||||
self.stripe.get_saved_payment_method(&tenant.stripe_customer_id).await?
|
self.stripe.get_saved_payment_method(&tenant.stripe_customer_id).await?
|
||||||
{
|
{
|
||||||
@@ -298,15 +303,11 @@ impl Billing {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(()) => return Ok(()),
|
Ok(()) => return Ok(()),
|
||||||
Err(e) => {
|
Err(e) => error_message = error_message.or_else(|| Some(format!("{e}"))),
|
||||||
let message = format!("{e}");
|
|
||||||
command::set_tenant_stripe_error(&tenant.pubkey, &message).await?;
|
|
||||||
error_message = error_message.or_else(|| Some(message));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
let summary = error_message.as_deref().and_then(summarize_error_message);
|
||||||
if let Err(e) = self.attempt_payment_using_dm(tenant, invoice, summary).await {
|
if let Err(e) = self.attempt_payment_using_dm(tenant, invoice, summary).await {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
@@ -320,26 +321,24 @@ impl Billing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn attempt_payment_using_nwc(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> {
|
async fn attempt_payment_using_nwc(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> {
|
||||||
let nwc_url = env::get().decrypt(&tenant.nwc_url)?;
|
let result: Result<()> = async {
|
||||||
let tenant_wallet = Wallet::from_url(&nwc_url)?;
|
let nwc_url = env::get().decrypt(&tenant.nwc_url)?;
|
||||||
let bolt11 = self.ensure_bolt11(&invoice.id).await?;
|
let tenant_wallet = Wallet::from_url(&nwc_url)?;
|
||||||
|
let bolt11 = self.ensure_bolt11(invoice).await?;
|
||||||
|
|
||||||
match tenant_wallet.pay_invoice(bolt11.lnbc.clone()).await {
|
tenant_wallet.pay_invoice(bolt11.lnbc.clone()).await?;
|
||||||
Ok(()) => {
|
|
||||||
command::clear_tenant_nwc_error(&tenant.pubkey).await?;
|
command::settle_invoice_via_nwc(&tenant.pubkey, &bolt11.id, &invoice.id).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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.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(
|
async fn attempt_payment_using_stripe(
|
||||||
@@ -348,24 +347,28 @@ impl Billing {
|
|||||||
invoice: &Invoice,
|
invoice: &Invoice,
|
||||||
payment_method_id: &str,
|
payment_method_id: &str,
|
||||||
) -> Result<()> {
|
) -> 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
|
command::settle_invoice_via_stripe(&tenant.pubkey, &intent_id, &invoice.id).await
|
||||||
// the cascade falls back to the manual DM.
|
}
|
||||||
let intent_id = self
|
.await;
|
||||||
.stripe
|
|
||||||
.create_payment_intent(
|
|
||||||
&tenant.stripe_customer_id,
|
|
||||||
payment_method_id,
|
|
||||||
&invoice.id,
|
|
||||||
amount,
|
|
||||||
"usd",
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
command::insert_intent(&intent_id, &invoice.id).await?;
|
// Record the failure on the tenant (to warn them in the UI) but still
|
||||||
command::clear_tenant_stripe_error(&tenant.pubkey).await?;
|
// surface it, so the cascade can fall through and summarize it in the DM.
|
||||||
command::mark_invoice_paid(&invoice.id, "stripe").await
|
if let Err(error) = &result {
|
||||||
|
command::set_tenant_stripe_error(&tenant.pubkey, &format!("{error}")).await?;
|
||||||
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn attempt_payment_using_dm(
|
async fn attempt_payment_using_dm(
|
||||||
@@ -401,7 +404,8 @@ impl Billing {
|
|||||||
let now = chrono::Utc::now().timestamp();
|
let now = chrono::Utc::now().timestamp();
|
||||||
if now - oldest.created_at >= GRACE_PERIOD_SECS {
|
if now - oldest.created_at >= GRACE_PERIOD_SECS {
|
||||||
if tenant.churned_at.is_none() {
|
if tenant.churned_at.is_none() {
|
||||||
self.churn_tenant(tenant, now).await?;
|
let relays = query::list_relays_for_tenant(&tenant.pubkey).await?;
|
||||||
|
command::churn_tenant(&tenant.pubkey, now, &relays).await?;
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -413,50 +417,12 @@ impl Billing {
|
|||||||
Ok(())
|
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_for_tenant(&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_for_tenant(&tenant.pubkey).await? {
|
|
||||||
if relay.status == RELAY_STATUS_DELINQUENT {
|
|
||||||
command::unmark_relay_delinquent(&relay).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
command::void_open_invoices(&tenant.pubkey).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Invoice utils ---
|
|
||||||
|
|
||||||
pub async fn get_invoice_amount(&self, invoice_id: &str) -> Result<i64> {
|
|
||||||
let invoice_items = query::get_invoice_items_for_invoice(invoice_id).await?;
|
|
||||||
|
|
||||||
Ok(invoice_items.iter().map(|item| item.amount).sum())
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Bolt11 utils ---
|
// --- 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();
|
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)
|
&& (existing.settled_at.is_none() || now < existing.expires_at)
|
||||||
{
|
{
|
||||||
return Ok(existing);
|
return Ok(existing);
|
||||||
@@ -464,11 +430,10 @@ impl Billing {
|
|||||||
|
|
||||||
let expiry: i64 = 3600;
|
let expiry: i64 = 3600;
|
||||||
let info = "Relay subscription payment";
|
let info = "Relay subscription payment";
|
||||||
let amount = self.get_invoice_amount(invoice_id).await?;
|
let msats = bitcoin::fiat_to_msats(invoice.amount, "usd").await?;
|
||||||
let msats = bitcoin::fiat_to_msats(amount, "usd").await?;
|
|
||||||
let lnbc = self.wallet.make_invoice(msats, info, expiry as u64).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?
|
.await?
|
||||||
.ok_or_else(|| anyhow!("failed to insert bolt11"))
|
.ok_or_else(|| anyhow!("failed to insert bolt11"))
|
||||||
}
|
}
|
||||||
@@ -478,11 +443,11 @@ impl Billing {
|
|||||||
/// settled on the robot wallet, mark it paid and return the refreshed record;
|
/// settled on the robot wallet, mark it paid and return the refreshed record;
|
||||||
/// otherwise return it unchanged. Meant to run before presenting a payable
|
/// otherwise return it unchanged. Meant to run before presenting a payable
|
||||||
/// invoice so we never hand back one that's already been paid.
|
/// 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> {
|
pub async fn ensure_and_reconcile_bolt11(&self, invoice: &Invoice) -> Result<Bolt11> {
|
||||||
let bolt11 = self.ensure_bolt11(invoice_id).await?;
|
let bolt11 = self.ensure_bolt11(invoice).await?;
|
||||||
|
|
||||||
if bolt11.settled_at.is_none() && self.wallet.is_settled(&bolt11.lnbc).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.
|
// Re-fetch so the caller sees that it's been settled.
|
||||||
Ok(query::get_bolt11(&bolt11.id).await?.unwrap_or(bolt11))
|
Ok(query::get_bolt11(&bolt11.id).await?.unwrap_or(bolt11))
|
||||||
|
|||||||
+208
-101
@@ -52,14 +52,6 @@ pub async fn set_tenant_nwc_error(pubkey: &str, error: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn clear_tenant_nwc_error(pubkey: &str) -> Result<()> {
|
|
||||||
sqlx::query("UPDATE tenant SET nwc_error = NULL WHERE pubkey = ?")
|
|
||||||
.bind(pubkey)
|
|
||||||
.execute(pool())
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_tenant_stripe_error(pubkey: &str, error: &str) -> Result<()> {
|
pub async fn set_tenant_stripe_error(pubkey: &str, error: &str) -> Result<()> {
|
||||||
sqlx::query("UPDATE tenant SET stripe_error = ? WHERE pubkey = ?")
|
sqlx::query("UPDATE tenant SET stripe_error = ? WHERE pubkey = ?")
|
||||||
.bind(error)
|
.bind(error)
|
||||||
@@ -69,22 +61,59 @@ pub async fn set_tenant_stripe_error(pubkey: &str, error: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn clear_tenant_stripe_error(pubkey: &str) -> Result<()> {
|
/// Atomically churn a tenant whose grace period has elapsed: set the churn
|
||||||
sqlx::query("UPDATE tenant SET stripe_error = NULL WHERE pubkey = ?")
|
/// marker, mark every active relay delinquent, and void the unpaid invoices.
|
||||||
.bind(pubkey)
|
pub async fn churn_tenant(tenant_pubkey: &str, now: i64, relays: &[Relay]) -> Result<()> {
|
||||||
.execute(pool())
|
let activities = with_tx(async |tx| {
|
||||||
.await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set or clear the tenant's churn marker. Set when an invoice ages past the
|
/// Atomically re-activate a churned tenant: clear the churn marker, restore every
|
||||||
/// grace period, cleared when billing is re-activated.
|
/// delinquent relay to active, and void any still-open invoices.
|
||||||
pub async fn set_tenant_churned_at(pubkey: &str, churned_at: Option<i64>) -> Result<()> {
|
pub async fn reactivate_tenant(tenant_pubkey: &str, relays: &[Relay]) -> Result<()> {
|
||||||
sqlx::query("UPDATE tenant SET churned_at = ? WHERE pubkey = ?")
|
let activities = with_tx(async |tx| {
|
||||||
.bind(churned_at)
|
set_tenant_churned_at_tx(tx, tenant_pubkey, None).await?;
|
||||||
.bind(pubkey)
|
|
||||||
.execute(pool())
|
let mut activities = Vec::new();
|
||||||
.await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,31 +206,9 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn mark_relay_delinquent(relay: &Relay) -> Result<()> {
|
|
||||||
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 =
|
||||||
sqlx::query("UPDATE relay SET status = ?, synced = 0 WHERE id = ?")
|
with_tx(async |tx| set_relay_status_tx(tx, relay, status, activity_type).await).await?;
|
||||||
.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?;
|
|
||||||
publish(activity);
|
publish(activity);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -332,7 +339,7 @@ pub async fn create_invoice(tenant: &Tenant, period: &BillingPeriod) -> Result<O
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
let invoice = insert_invoice_tx(tx, &tenant, &period).await?;
|
let invoice = insert_invoice_tx(tx, &tenant, &period, total).await?;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE invoice_item SET invoice_id = ?
|
"UPDATE invoice_item SET invoice_id = ?
|
||||||
@@ -348,32 +355,45 @@ pub async fn create_invoice(tenant: &Tenant, period: &BillingPeriod) -> Result<O
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn mark_invoice_paid(invoice_id: &str, method: &str) -> Result<()> {
|
// --- Payment settlement ---
|
||||||
let paid_at = chrono::Utc::now().timestamp();
|
|
||||||
|
|
||||||
sqlx::query("UPDATE invoice SET method = ?, paid_at = ? WHERE id = ?")
|
/// Atomically record an NWC-settled invoice: clear the tenant's stored NWC error,
|
||||||
.bind(method)
|
/// mark the bolt11 settled, and mark the invoice paid.
|
||||||
.bind(paid_at)
|
pub async fn settle_invoice_via_nwc(
|
||||||
.bind(invoice_id)
|
tenant_pubkey: &str,
|
||||||
.execute(pool())
|
bolt11_id: &str,
|
||||||
.await?;
|
invoice_id: &str,
|
||||||
Ok(())
|
) -> 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Void all of a tenant's open invoices, forgiving the balance — used when a
|
/// Atomically record a Lightning settlement that happened out of band.
|
||||||
/// tenant churns or re-activates, so old debt never has to be collected.
|
pub async fn settle_invoice_out_of_band(bolt11_id: &str, invoice_id: &str) -> Result<()> {
|
||||||
pub async fn void_open_invoices(tenant_pubkey: &str) -> Result<()> {
|
with_tx(async |tx| {
|
||||||
let voided_at = chrono::Utc::now().timestamp();
|
mark_bolt11_settled_tx(tx, bolt11_id).await?;
|
||||||
|
mark_invoice_paid_tx(tx, invoice_id, "oob").await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
sqlx::query(
|
/// Atomically record a Stripe-settled invoice: persist the PaymentIntent, clear
|
||||||
"UPDATE invoice SET voided_at = ?
|
/// the tenant's stored Stripe error, and mark the invoice paid.
|
||||||
WHERE tenant_pubkey = ? AND paid_at IS NULL AND voided_at IS NULL",
|
pub async fn settle_invoice_via_stripe(
|
||||||
)
|
tenant_pubkey: &str,
|
||||||
.bind(voided_at)
|
intent_id: &str,
|
||||||
.bind(tenant_pubkey)
|
invoice_id: &str,
|
||||||
.execute(pool())
|
) -> Result<()> {
|
||||||
.await?;
|
with_tx(async |tx| {
|
||||||
Ok(())
|
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 ---
|
// --- Bolt11 records ---
|
||||||
@@ -401,37 +421,6 @@ pub async fn insert_bolt11(
|
|||||||
.await?)
|
.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 ---
|
// --- Internal utils that take an explicit transaction ---
|
||||||
|
|
||||||
async fn insert_activity_tx(
|
async fn insert_activity_tx(
|
||||||
@@ -484,16 +473,18 @@ async fn insert_invoice_tx(
|
|||||||
tx: &mut Transaction<'_, Sqlite>,
|
tx: &mut Transaction<'_, Sqlite>,
|
||||||
tenant: &Tenant,
|
tenant: &Tenant,
|
||||||
period: &BillingPeriod,
|
period: &BillingPeriod,
|
||||||
|
amount: i64,
|
||||||
) -> Result<Invoice> {
|
) -> Result<Invoice> {
|
||||||
let now = chrono::Utc::now().timestamp();
|
let now = chrono::Utc::now().timestamp();
|
||||||
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, period_start, period_end, created_at)
|
"INSERT INTO invoice (id, tenant_pubkey, amount, period_start, period_end, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?) RETURNING *",
|
VALUES (?, ?, ?, ?, ?, ?) RETURNING *",
|
||||||
)
|
)
|
||||||
.bind(invoice_id)
|
.bind(invoice_id)
|
||||||
.bind(&tenant.pubkey)
|
.bind(&tenant.pubkey)
|
||||||
|
.bind(amount)
|
||||||
.bind(&period.start)
|
.bind(&period.start)
|
||||||
.bind(period.end)
|
.bind(period.end)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
@@ -536,3 +527,119 @@ async fn mark_activity_billed_tx(
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(result.rows_affected() > 0)
|
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(())
|
||||||
|
}
|
||||||
|
|||||||
+3
-16
@@ -124,6 +124,9 @@ impl Default for Relay {
|
|||||||
pub struct Invoice {
|
pub struct Invoice {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub tenant_pubkey: String,
|
pub tenant_pubkey: String,
|
||||||
|
/// The total owed, fixed when the invoice is cut from its outstanding line
|
||||||
|
/// items, so collection never has to re-sum them.
|
||||||
|
pub amount: i64,
|
||||||
pub period_start: i64,
|
pub period_start: i64,
|
||||||
pub period_end: i64,
|
pub period_end: i64,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
@@ -131,13 +134,6 @@ pub struct Invoice {
|
|||||||
pub voided_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)]
|
||||||
pub struct InvoiceItem {
|
pub struct InvoiceItem {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -163,12 +159,3 @@ pub struct Bolt11 {
|
|||||||
pub expires_at: i64,
|
pub expires_at: i64,
|
||||||
pub settled_at: Option<i64>,
|
pub settled_at: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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)]
|
|
||||||
pub struct Intent {
|
|
||||||
pub id: String,
|
|
||||||
pub invoice_id: String,
|
|
||||||
pub created_at: i64,
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-24
@@ -1,6 +1,6 @@
|
|||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
|
|
||||||
use crate::models::{Activity, Bolt11, Intent, Invoice, InvoiceItem, Plan, Relay, Tenant};
|
use crate::models::{Activity, Bolt11, Invoice, Plan, Relay, Tenant};
|
||||||
use crate::db::pool;
|
use crate::db::pool;
|
||||||
|
|
||||||
fn select_tenant(tail: &str) -> String {
|
fn select_tenant(tail: &str) -> String {
|
||||||
@@ -156,29 +156,6 @@ pub async fn list_open_invoices(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
|
|||||||
.await?)
|
.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?,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 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>> {
|
||||||
|
|||||||
@@ -1,53 +1,11 @@
|
|||||||
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
|
||||||
@@ -65,7 +23,7 @@ pub async fn get_tenant_latest_invoice(
|
|||||||
|
|
||||||
let invoice = query::get_latest_invoice(&pubkey).await.map_err(internal)?;
|
let invoice = query::get_latest_invoice(&pubkey).await.map_err(internal)?;
|
||||||
|
|
||||||
ok(invoice.map(InvoiceResponse::from))
|
ok(invoice)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_invoice(
|
pub async fn get_invoice(
|
||||||
@@ -80,10 +38,7 @@ pub async fn get_invoice(
|
|||||||
|
|
||||||
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
|
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
|
||||||
|
|
||||||
let mut response = InvoiceResponse::from(invoice);
|
ok(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
|
||||||
@@ -102,7 +57,7 @@ pub async fn get_invoice_bolt11(
|
|||||||
|
|
||||||
let bolt11 = api
|
let bolt11 = api
|
||||||
.billing
|
.billing
|
||||||
.ensure_and_reconcile_bolt11(&invoice_id)
|
.ensure_and_reconcile_bolt11(&invoice)
|
||||||
.await
|
.await
|
||||||
.map_err(internal)?;
|
.map_err(internal)?;
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ 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};
|
||||||
|
|
||||||
@@ -162,10 +161,7 @@ pub async fn list_tenant_invoices(
|
|||||||
.await
|
.await
|
||||||
.map_err(internal)?;
|
.map_err(internal)?;
|
||||||
|
|
||||||
ok(invoices
|
ok(invoices)
|
||||||
.into_iter()
|
|
||||||
.map(InvoiceResponse::from)
|
|
||||||
.collect::<Vec<_>>())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { A, useLocation } from "@solidjs/router"
|
|||||||
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
|
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
|
||||||
import Fuse from "fuse.js"
|
import Fuse from "fuse.js"
|
||||||
import { primeProfiles, useProfilePicture, useTenant, useTenantRelays, type Relay } from "@/lib/hooks"
|
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 { account, eventStore, identity } from "@/lib/state"
|
||||||
import serverIcon from "@/assets/server.svg"
|
import serverIcon from "@/assets/server.svg"
|
||||||
import Modal from "@/components/Modal"
|
import Modal from "@/components/Modal"
|
||||||
@@ -51,7 +51,7 @@ export default function AppShell(props: { children?: any }) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const invoices = await listTenantInvoices(t.pubkey)
|
const invoices = await listTenantInvoices(t.pubkey)
|
||||||
const openInvoice = invoices.find(inv => inv.status === "open")
|
const openInvoice = invoices.find(inv => invoiceStatus(inv) === "open")
|
||||||
setPastDueInvoice(openInvoice)
|
setPastDueInvoice(openInvoice)
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { plans } from "@/lib/state"
|
|||||||
type PayStatus = "idle" | "loading" | "success" | "error"
|
type PayStatus = "idle" | "loading" | "success" | "error"
|
||||||
type Bolt11Status = "idle" | "loading" | "ready" | "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">>
|
Partial<Pick<Invoice, "period_start" | "period_end">>
|
||||||
|
|
||||||
type PaymentDialogProps = {
|
type PaymentDialogProps = {
|
||||||
@@ -68,7 +68,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
|||||||
setPayError("")
|
setPayError("")
|
||||||
try {
|
try {
|
||||||
const invoice = await getInvoice(props.invoice.id)
|
const invoice = await getInvoice(props.invoice.id)
|
||||||
if (invoice.status === "paid") {
|
if (invoice.paid_at != null) {
|
||||||
setPayStatus("success")
|
setPayStatus("success")
|
||||||
} else {
|
} else {
|
||||||
setPayStatus("error")
|
setPayStatus("error")
|
||||||
@@ -90,7 +90,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
|
|||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const amountLabel = () => `$${(props.invoice.amount_due / 100).toFixed(2)}`
|
const amountLabel = () => `$${(props.invoice.amount / 100).toFixed(2)}`
|
||||||
|
|
||||||
const periodLabel = () => {
|
const periodLabel = () => {
|
||||||
const { period_start, period_end } = props.invoice
|
const { period_start, period_end } = props.invoice
|
||||||
|
|||||||
+14
-4
@@ -106,12 +106,22 @@ export type Tenant = {
|
|||||||
|
|
||||||
export type Invoice = {
|
export type Invoice = {
|
||||||
id: string
|
id: string
|
||||||
customer: string
|
tenant_pubkey: string
|
||||||
status: string
|
amount: number
|
||||||
amount_due: number
|
|
||||||
currency: string
|
|
||||||
period_start: number
|
period_start: number
|
||||||
period_end: 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 = {
|
export type Activity = {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
reactivateRelay,
|
reactivateRelay,
|
||||||
getRelay,
|
getRelay,
|
||||||
getTenant,
|
getTenant,
|
||||||
|
invoiceStatus,
|
||||||
listRelayActivity,
|
listRelayActivity,
|
||||||
listRelays,
|
listRelays,
|
||||||
listTenantInvoices,
|
listTenantInvoices,
|
||||||
@@ -141,7 +142,7 @@ export async function tenantNeedsPaymentSetup(): Promise<boolean> {
|
|||||||
export async function getLatestOpenInvoice(): Promise<Invoice | null> {
|
export async function getLatestOpenInvoice(): Promise<Invoice | null> {
|
||||||
const invoices = await listTenantInvoices(account()!.pubkey)
|
const invoices = await listTenantInvoices(account()!.pubkey)
|
||||||
const open = invoices
|
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)
|
.sort((a, b) => b.period_start - a.period_start)
|
||||||
return open[0] ?? null
|
return open[0] ?? null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import LoadingState from "@/components/LoadingState"
|
|||||||
import PaymentDialog from "@/components/PaymentDialog"
|
import PaymentDialog from "@/components/PaymentDialog"
|
||||||
import useMinLoading from "@/components/useMinLoading"
|
import useMinLoading from "@/components/useMinLoading"
|
||||||
import { updateActiveTenant, useTenant } from "@/lib/hooks"
|
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"
|
import { account } from "@/lib/state"
|
||||||
|
|
||||||
export default function Account() {
|
export default function Account() {
|
||||||
@@ -75,11 +75,9 @@ export default function Account() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const invoiceStatusStyles: Record<string, string> = {
|
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",
|
open: "bg-yellow-50 text-yellow-700 border-yellow-200",
|
||||||
paid: "bg-green-50 text-green-700 border-green-200",
|
paid: "bg-green-50 text-green-700 border-green-200",
|
||||||
void: "bg-gray-100 text-gray-500 border-gray-200",
|
void: "bg-gray-100 text-gray-500 border-gray-200",
|
||||||
uncollectible: "bg-red-50 text-red-700 border-red-200",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -168,8 +166,9 @@ export default function Account() {
|
|||||||
<ul class="space-y-3">
|
<ul class="space-y-3">
|
||||||
<For each={invoices()}>
|
<For each={invoices()}>
|
||||||
{(invoice) => {
|
{(invoice) => {
|
||||||
const isOpen = () => invoice.status === "open"
|
const status = () => invoiceStatus(invoice)
|
||||||
const statusStyle = () => invoiceStatusStyles[invoice.status] ?? "bg-gray-100 text-gray-500 border-gray-200"
|
const isOpen = () => status() === "open"
|
||||||
|
const statusStyle = () => invoiceStatusStyles[status()] ?? "bg-gray-100 text-gray-500 border-gray-200"
|
||||||
const periodLabel = () => {
|
const periodLabel = () => {
|
||||||
const start = new Date(invoice.period_start * 1000)
|
const start = new Date(invoice.period_start * 1000)
|
||||||
const end = new Date(invoice.period_end * 1000)
|
const end = new Date(invoice.period_end * 1000)
|
||||||
@@ -185,7 +184,7 @@ export default function Account() {
|
|||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<span class="font-medium text-gray-900">
|
<span class="font-medium text-gray-900">
|
||||||
${(invoice.amount_due / 100).toFixed(2)}
|
${(invoice.amount / 100).toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
<Show when={invoice.period_start && invoice.period_end}>
|
<Show when={invoice.period_start && invoice.period_end}>
|
||||||
<p class="text-xs text-gray-500 mt-0.5">{periodLabel()}</p>
|
<p class="text-xs text-gray-500 mt-0.5">{periodLabel()}</p>
|
||||||
@@ -196,7 +195,7 @@ export default function Account() {
|
|||||||
<span class="text-xs text-blue-600 font-medium">Pay now</span>
|
<span class="text-xs text-blue-600 font-medium">Pay now</span>
|
||||||
</Show>
|
</Show>
|
||||||
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${statusStyle()}`}>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user