forked from coracle/caravel
Add snapshots to activity
This commit is contained in:
@@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS activity (
|
|||||||
resource_type TEXT NOT NULL,
|
resource_type TEXT NOT NULL,
|
||||||
resource_id TEXT NOT NULL,
|
resource_id TEXT NOT NULL,
|
||||||
billed_at INTEGER,
|
billed_at INTEGER,
|
||||||
plan_id TEXT,
|
snapshot TEXT NOT NULL,
|
||||||
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
|
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+24
-63
@@ -1,11 +1,10 @@
|
|||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::time::Duration;
|
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, Tenant};
|
use crate::models::{Activity, Bolt11, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, Snapshot, Tenant};
|
||||||
use crate::query;
|
use crate::query;
|
||||||
use crate::robot::Robot;
|
use crate::robot::Robot;
|
||||||
use crate::stripe::Stripe;
|
use crate::stripe::Stripe;
|
||||||
@@ -141,9 +140,7 @@ impl Billing {
|
|||||||
let Some(relay) = query::get_relay(&activity.resource_id).await? else {
|
let Some(relay) = query::get_relay(&activity.resource_id).await? else {
|
||||||
return Err(anyhow!("activity resource was not a valid relay"));
|
return Err(anyhow!("activity resource was not a valid relay"));
|
||||||
};
|
};
|
||||||
let Some(plan) = query::get_plan(&relay.plan_id) else {
|
let plan = query::get_plan(&relay.plan_id)?;
|
||||||
return Err(anyhow!("activity plan was not a valid plan"));
|
|
||||||
};
|
|
||||||
if plan.amount <= 0 {
|
if plan.amount <= 0 {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
@@ -175,23 +172,19 @@ impl Billing {
|
|||||||
tenant: &Tenant,
|
tenant: &Tenant,
|
||||||
activity: &Activity,
|
activity: &Activity,
|
||||||
) -> Result<Option<InvoiceItem>> {
|
) -> Result<Option<InvoiceItem>> {
|
||||||
let Some(new_plan_id) = activity.plan_id.as_deref() else {
|
let new_plan_id = match &*activity.snapshot {
|
||||||
return Err(anyhow!("activity plan was not a valid plan"));
|
Snapshot::Relay { plan, .. } => plan,
|
||||||
};
|
};
|
||||||
let Some(old_plan_id) =
|
let Some(old_plan_id) =
|
||||||
query::get_relay_plan_before(&activity.resource_id, activity.created_at).await?
|
query::get_relay_plan_before(&activity.resource_id, activity.created_at).await?
|
||||||
else {
|
else {
|
||||||
return Err(anyhow!("no previous plan found for relay update activity"));
|
return Err(anyhow!("no previous plan found for relay update activity"));
|
||||||
};
|
};
|
||||||
if old_plan_id == new_plan_id {
|
if &old_plan_id == new_plan_id {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let Some(new_plan) = query::get_plan(new_plan_id) else {
|
let new_plan = query::get_plan(new_plan_id)?;
|
||||||
return Err(anyhow!("new plan is an invalid plan"));
|
let old_plan = query::get_plan(&old_plan_id)?;
|
||||||
};
|
|
||||||
let Some(old_plan) = query::get_plan(&old_plan_id) else {
|
|
||||||
return Err(anyhow!("old plan is an invalid plan"));
|
|
||||||
};
|
|
||||||
|
|
||||||
let period = BillingPeriod::at(tenant, activity.created_at)
|
let period = BillingPeriod::at(tenant, activity.created_at)
|
||||||
.ok_or_else(|| anyhow!("billing anchor must be set before building prorated items"))?;
|
.ok_or_else(|| anyhow!("billing anchor must be set before building prorated items"))?;
|
||||||
@@ -216,23 +209,27 @@ impl Billing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Charge a full-period renewal for every relay that was active on a paid plan
|
/// Charge a full-period renewal for every relay that was active on a paid plan
|
||||||
/// as of `period.start`, reconstructing that state from the activity log
|
/// as of `period.start`, reading that state from each relay's most recent
|
||||||
/// (status from create/activate/deactivate, plan from create/update).
|
/// activity snapshot before the boundary (relays with no prior activity didn't
|
||||||
/// Idempotent per period via the tenant's `renewed_at` marker, so calling it
|
/// exist yet and are skipped). Idempotent per period via the tenant's
|
||||||
/// on every generation can't renew twice; a relay created/activated *within*
|
/// `renewed_at` marker, so calling it on every generation can't renew twice;
|
||||||
/// the period isn't active before the boundary, so it's covered by its own
|
/// a relay created/activated *within* the period isn't active before the
|
||||||
/// 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 activities = query::list_relay_activity_before(&tenant.pubkey, period.start).await?;
|
let relays = query::list_relays_for_tenant(&tenant.pubkey).await?;
|
||||||
|
|
||||||
let mut line_items = Vec::new();
|
let mut line_items = Vec::new();
|
||||||
for (relay_id, state) in relay_states(&activities) {
|
for relay in relays {
|
||||||
if !state.active {
|
let Some(activity) =
|
||||||
continue;
|
query::get_latest_relay_activity_before(&relay.id, period.start).await?
|
||||||
}
|
else {
|
||||||
let Some(plan) = state.plan_id.and_then(|id| query::get_plan(&id)) else {
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
let Snapshot::Relay { plan: plan_id, status, .. } = &*activity.snapshot;
|
||||||
|
if status != RELAY_STATUS_ACTIVE {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let plan = query::get_plan(plan_id)?;
|
||||||
if plan.amount <= 0 {
|
if plan.amount <= 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -241,7 +238,7 @@ impl Billing {
|
|||||||
invoice_id: None,
|
invoice_id: None,
|
||||||
activity_id: None,
|
activity_id: None,
|
||||||
tenant_pubkey: tenant.pubkey.clone(),
|
tenant_pubkey: tenant.pubkey.clone(),
|
||||||
relay_id,
|
relay_id: relay.id,
|
||||||
plan_id: plan.id,
|
plan_id: plan.id,
|
||||||
amount: plan.amount,
|
amount: plan.amount,
|
||||||
description: "Subscription renewal".to_string(),
|
description: "Subscription renewal".to_string(),
|
||||||
@@ -476,42 +473,6 @@ impl BillingPeriod {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A relay's billing-relevant state at a point in time, reconstructed by folding
|
|
||||||
/// its activity log.
|
|
||||||
#[derive(Default)]
|
|
||||||
struct RelayState {
|
|
||||||
active: bool,
|
|
||||||
plan_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fold relay activities (which must be oldest-first) into each relay's
|
|
||||||
/// `(active, plan)` state. `create`/`activate`/`deactivate` drive status;
|
|
||||||
/// `create`/`update` carry the plan via `plan_id`. Feed it activities up to a
|
|
||||||
/// cutoff to get each relay's state as of that moment (e.g. the period boundary).
|
|
||||||
fn relay_states(activities: &[Activity]) -> HashMap<String, RelayState> {
|
|
||||||
let mut states: HashMap<String, RelayState> = HashMap::new();
|
|
||||||
|
|
||||||
for activity in activities {
|
|
||||||
let state = states.entry(activity.resource_id.clone()).or_default();
|
|
||||||
match activity.activity_type.as_str() {
|
|
||||||
"create_relay" => {
|
|
||||||
state.active = true;
|
|
||||||
state.plan_id = activity.plan_id.clone();
|
|
||||||
}
|
|
||||||
"update_relay" => {
|
|
||||||
if activity.plan_id.is_some() {
|
|
||||||
state.plan_id = activity.plan_id.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"activate_relay" => state.active = true,
|
|
||||||
"deactivate_relay" => state.active = false,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
states
|
|
||||||
}
|
|
||||||
|
|
||||||
fn summarize_error_message(error: &str) -> Option<String> {
|
fn summarize_error_message(error: &str) -> Option<String> {
|
||||||
let normalized = error.split_whitespace().collect::<Vec<_>>().join(" ");
|
let normalized = error.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
if normalized.is_empty() {
|
if normalized.is_empty() {
|
||||||
|
|||||||
+62
-57
@@ -1,45 +1,36 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use sqlx::types::Json;
|
||||||
use sqlx::{Sqlite, Transaction};
|
use sqlx::{Sqlite, Transaction};
|
||||||
|
|
||||||
use crate::billing::BillingPeriod;
|
use crate::billing::BillingPeriod;
|
||||||
use crate::db::{pool, publish, with_tx};
|
use crate::db::{pool, publish, with_tx};
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
Activity, Bolt11, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT,
|
Activity, Bolt11, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT,
|
||||||
RELAY_STATUS_INACTIVE, Relay, Tenant,
|
RELAY_STATUS_INACTIVE, Relay, Snapshot, Tenant,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Tenants ---
|
// --- Tenants ---
|
||||||
|
|
||||||
pub async fn create_tenant(tenant: &Tenant) -> Result<()> {
|
pub async fn create_tenant(tenant: &Tenant) -> Result<()> {
|
||||||
let activity = with_tx(async |tx| {
|
sqlx::query(
|
||||||
sqlx::query(
|
"INSERT INTO tenant (pubkey, nwc_url, created_at, stripe_customer_id)
|
||||||
"INSERT INTO tenant (pubkey, nwc_url, created_at, stripe_customer_id)
|
VALUES (?, ?, ?, ?)",
|
||||||
VALUES (?, ?, ?, ?)",
|
)
|
||||||
)
|
.bind(&tenant.pubkey)
|
||||||
.bind(&tenant.pubkey)
|
.bind(&tenant.nwc_url)
|
||||||
.bind(&tenant.nwc_url)
|
.bind(tenant.created_at)
|
||||||
.bind(tenant.created_at)
|
.bind(&tenant.stripe_customer_id)
|
||||||
.bind(&tenant.stripe_customer_id)
|
.execute(pool())
|
||||||
.execute(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
insert_activity_tx(tx, "create_tenant", "tenant", &tenant.pubkey, None).await
|
|
||||||
})
|
|
||||||
.await?;
|
.await?;
|
||||||
publish(activity);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_tenant(tenant: &Tenant) -> Result<()> {
|
pub async fn update_tenant(tenant: &Tenant) -> Result<()> {
|
||||||
let activity = with_tx(async |tx| {
|
sqlx::query("UPDATE tenant SET nwc_url = ? WHERE pubkey = ?")
|
||||||
sqlx::query("UPDATE tenant SET nwc_url = ? WHERE pubkey = ?")
|
.bind(&tenant.nwc_url)
|
||||||
.bind(&tenant.nwc_url)
|
.bind(&tenant.pubkey)
|
||||||
.bind(&tenant.pubkey)
|
.execute(pool())
|
||||||
.execute(&mut **tx)
|
.await?;
|
||||||
.await?;
|
|
||||||
insert_activity_tx(tx, "update_tenant", "tenant", &tenant.pubkey, None).await
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
publish(activity);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +81,11 @@ pub async fn create_relay(relay: &Relay) -> Result<()> {
|
|||||||
.bind(relay.push_enabled)
|
.bind(relay.push_enabled)
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
insert_activity_tx(tx, "create_relay", "relay", &relay.id, Some(&relay.plan_id)).await
|
let snapshot = Snapshot::Relay {
|
||||||
|
plan: relay.plan_id.clone(),
|
||||||
|
status: RELAY_STATUS_ACTIVE.to_string(),
|
||||||
|
};
|
||||||
|
insert_activity_tx(tx, "create_relay", &relay.id, snapshot).await
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
publish(activity);
|
publish(activity);
|
||||||
@@ -126,7 +121,11 @@ pub async fn update_relay(relay: &Relay) -> Result<()> {
|
|||||||
.bind(&relay.id)
|
.bind(&relay.id)
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
insert_activity_tx(tx, "update_relay", "relay", &relay.id, Some(&relay.plan_id)).await
|
let snapshot = Snapshot::Relay {
|
||||||
|
plan: relay.plan_id.clone(),
|
||||||
|
status: relay.status.clone(),
|
||||||
|
};
|
||||||
|
insert_activity_tx(tx, "update_relay", &relay.id, snapshot).await
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
publish(activity);
|
publish(activity);
|
||||||
@@ -134,26 +133,30 @@ pub async fn update_relay(relay: &Relay) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn activate_relay(relay: &Relay) -> Result<()> {
|
pub async fn activate_relay(relay: &Relay) -> Result<()> {
|
||||||
set_relay_status(&relay.id, RELAY_STATUS_ACTIVE, "activate_relay").await
|
set_relay_status(relay, RELAY_STATUS_ACTIVE, "activate_relay").await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn deactivate_relay(relay: &Relay) -> Result<()> {
|
pub async fn deactivate_relay(relay: &Relay) -> Result<()> {
|
||||||
set_relay_status(&relay.id, 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)
|
#[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.id, RELAY_STATUS_DELINQUENT, "mark_relay_delinquent").await
|
set_relay_status(relay, RELAY_STATUS_DELINQUENT, "mark_relay_delinquent").await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn set_relay_status(relay_id: &str, 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 = ?")
|
||||||
.bind(status)
|
.bind(status)
|
||||||
.bind(relay_id)
|
.bind(&relay.id)
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
insert_activity_tx(tx, activity_type, "relay", relay_id, None).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?;
|
.await?;
|
||||||
publish(activity);
|
publish(activity);
|
||||||
@@ -167,20 +170,28 @@ pub async fn fail_relay_sync(relay: &Relay, sync_error: String) -> Result<()> {
|
|||||||
.bind(&relay.id)
|
.bind(&relay.id)
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
insert_activity_tx(tx, "fail_relay_sync", "relay", &relay.id, None).await
|
let snapshot = Snapshot::Relay {
|
||||||
|
plan: relay.plan_id.clone(),
|
||||||
|
status: relay.status.clone(),
|
||||||
|
};
|
||||||
|
insert_activity_tx(tx, "fail_relay_sync", &relay.id, snapshot).await
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
publish(activity);
|
publish(activity);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn complete_relay_sync(relay_id: &str) -> Result<()> {
|
pub async fn complete_relay_sync(relay: &Relay) -> Result<()> {
|
||||||
let activity = with_tx(async |tx| {
|
let activity = with_tx(async |tx| {
|
||||||
sqlx::query("UPDATE relay SET synced = 1, sync_error = '' WHERE id = ?")
|
sqlx::query("UPDATE relay SET synced = 1, sync_error = '' WHERE id = ?")
|
||||||
.bind(relay_id)
|
.bind(&relay.id)
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
insert_activity_tx(tx, "complete_relay_sync", "relay", relay_id, None).await
|
let snapshot = Snapshot::Relay {
|
||||||
|
plan: relay.plan_id.clone(),
|
||||||
|
status: relay.status.clone(),
|
||||||
|
};
|
||||||
|
insert_activity_tx(tx, "complete_relay_sync", &relay.id, snapshot).await
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
publish(activity);
|
publish(activity);
|
||||||
@@ -297,17 +308,12 @@ 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 updated_at = chrono::Utc::now().timestamp();
|
||||||
|
|
||||||
let activity = with_tx(async |tx| {
|
sqlx::query("UPDATE invoice SET status = 'paid', method = ?, updated_at = ? WHERE id = ?")
|
||||||
sqlx::query("UPDATE invoice SET status = 'paid', method = ?, updated_at = ? WHERE id = ?")
|
.bind(method)
|
||||||
.bind(method)
|
.bind(updated_at)
|
||||||
.bind(updated_at)
|
.bind(invoice_id)
|
||||||
.bind(invoice_id)
|
.execute(pool())
|
||||||
.execute(&mut **tx)
|
.await?;
|
||||||
.await?;
|
|
||||||
insert_activity_tx(tx, "invoice_paid", "invoice", invoice_id, None).await
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
publish(activity);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,26 +378,25 @@ pub async fn insert_intent(intent_id: &str, invoice_id: &str) -> Result<()> {
|
|||||||
async fn insert_activity_tx(
|
async fn insert_activity_tx(
|
||||||
tx: &mut Transaction<'_, Sqlite>,
|
tx: &mut Transaction<'_, Sqlite>,
|
||||||
activity_type: &str,
|
activity_type: &str,
|
||||||
resource_type: &str,
|
|
||||||
resource_id: &str,
|
resource_id: &str,
|
||||||
plan_id: Option<&str>,
|
snapshot: Snapshot,
|
||||||
) -> Result<Activity> {
|
) -> Result<Activity> {
|
||||||
let tenant_pubkey = match resource_type {
|
let resource_type = snapshot.resource_type();
|
||||||
"tenant" => resource_id.to_string(),
|
let tenant_pubkey = match &snapshot {
|
||||||
"relay" => {
|
Snapshot::Relay { .. } => {
|
||||||
sqlx::query_scalar::<_, String>("SELECT tenant_pubkey FROM relay WHERE id = ?")
|
sqlx::query_scalar::<_, String>("SELECT tenant_pubkey FROM relay WHERE id = ?")
|
||||||
.bind(resource_id)
|
.bind(resource_id)
|
||||||
.fetch_one(&mut **tx)
|
.fetch_one(&mut **tx)
|
||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
_ => anyhow::bail!("unknown resource_type: {resource_type}"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let id = uuid::Uuid::new_v4().to_string();
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
let created_at = chrono::Utc::now().timestamp();
|
let created_at = chrono::Utc::now().timestamp();
|
||||||
|
let snapshot = Json(snapshot);
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO activity (id, tenant_pubkey, created_at, activity_type, resource_type, resource_id, plan_id)
|
"INSERT INTO activity (id, tenant_pubkey, created_at, activity_type, resource_type, resource_id, snapshot)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
@@ -400,7 +405,7 @@ async fn insert_activity_tx(
|
|||||||
.bind(activity_type)
|
.bind(activity_type)
|
||||||
.bind(resource_type)
|
.bind(resource_type)
|
||||||
.bind(resource_id)
|
.bind(resource_id)
|
||||||
.bind(plan_id)
|
.bind(&snapshot)
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -412,7 +417,7 @@ async fn insert_activity_tx(
|
|||||||
resource_type: resource_type.to_string(),
|
resource_type: resource_type.to_string(),
|
||||||
resource_id: resource_id.to_string(),
|
resource_id: resource_id.to_string(),
|
||||||
billed_at: None,
|
billed_at: None,
|
||||||
plan_id: plan_id.map(str::to_string),
|
snapshot,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ async fn sync_relay(relay: &Relay) {
|
|||||||
match try_sync_relay(relay).await {
|
match try_sync_relay(relay).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
tracing::info!(relay = %relay.id, "relay sync succeeded");
|
tracing::info!(relay = %relay.id, "relay sync succeeded");
|
||||||
if let Err(e) = command::complete_relay_sync(&relay.id).await {
|
if let Err(e) = command::complete_relay_sync(relay).await {
|
||||||
tracing::error!(relay = %relay.id, error = %e, "failed to mark sync complete");
|
tracing::error!(relay = %relay.id, error = %e, "failed to mark sync complete");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-3
@@ -1,9 +1,28 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::types::Json;
|
||||||
|
|
||||||
pub const RELAY_STATUS_ACTIVE: &str = "active";
|
pub const RELAY_STATUS_ACTIVE: &str = "active";
|
||||||
pub const RELAY_STATUS_INACTIVE: &str = "inactive";
|
pub const RELAY_STATUS_INACTIVE: &str = "inactive";
|
||||||
pub const RELAY_STATUS_DELINQUENT: &str = "delinquent";
|
pub const RELAY_STATUS_DELINQUENT: &str = "delinquent";
|
||||||
|
|
||||||
|
/// Per-resource_type snapshot of a resource's state captured on each activity,
|
||||||
|
/// stored as JSON in `activity.snapshot`. Tagged on `resource_type` so the JSON
|
||||||
|
/// is self-describing and the variant matches the activity row's column. Add a
|
||||||
|
/// variant per resource type that needs state preserved on the activity log.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "resource_type", rename_all = "snake_case")]
|
||||||
|
pub enum Snapshot {
|
||||||
|
Relay { plan: String, status: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Snapshot {
|
||||||
|
pub fn resource_type(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Relay { .. } => "relay",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Plan {
|
pub struct Plan {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -36,9 +55,7 @@ pub struct Activity {
|
|||||||
pub resource_type: String,
|
pub resource_type: String,
|
||||||
pub resource_id: String,
|
pub resource_id: String,
|
||||||
pub billed_at: Option<i64>,
|
pub billed_at: Option<i64>,
|
||||||
/// The relay's plan at the time of a `create_relay`/`update_relay` activity;
|
pub snapshot: Json<Snapshot>,
|
||||||
/// `None` for all other activity types.
|
|
||||||
pub plan_id: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
|||||||
+21
-20
@@ -1,4 +1,4 @@
|
|||||||
use anyhow::Result;
|
use anyhow::{Result, anyhow};
|
||||||
|
|
||||||
use crate::models::{Activity, Bolt11, Invoice, InvoiceItem, Plan, Relay, Tenant};
|
use crate::models::{Activity, Bolt11, Invoice, InvoiceItem, Plan, Relay, Tenant};
|
||||||
use crate::db::pool;
|
use crate::db::pool;
|
||||||
@@ -46,8 +46,11 @@ pub fn list_plans() -> Vec<Plan> {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_plan(plan_id: &str) -> Option<Plan> {
|
pub fn get_plan(plan_id: &str) -> Result<Plan> {
|
||||||
list_plans().into_iter().find(|p| p.id == plan_id)
|
list_plans()
|
||||||
|
.into_iter()
|
||||||
|
.find(|p| p.id == plan_id)
|
||||||
|
.ok_or_else(|| anyhow!("plan not found: {plan_id}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Tenants ---
|
// --- Tenants ---
|
||||||
@@ -95,15 +98,14 @@ pub async fn get_relay(id: &str) -> Result<Option<Relay>> {
|
|||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The relay's plan immediately before `before`, read from the activity log
|
/// The relay's plan immediately before `before`, read from the most recent
|
||||||
/// (the most recent `create_relay`/`update_relay` with `created_at < before`).
|
/// relay-activity snapshot with `created_at < before`. Billing uses this as
|
||||||
/// Billing uses this as the `old` side of a plan-change delta.
|
/// the `old` side of a plan-change delta.
|
||||||
pub async fn get_relay_plan_before(relay_id: &str, before: i64) -> Result<Option<String>> {
|
pub async fn get_relay_plan_before(relay_id: &str, before: i64) -> Result<Option<String>> {
|
||||||
Ok(sqlx::query_scalar::<_, String>(
|
Ok(sqlx::query_scalar::<_, String>(
|
||||||
"SELECT plan_id FROM activity
|
"SELECT json_extract(snapshot, '$.plan') FROM activity
|
||||||
WHERE resource_id = ?
|
WHERE resource_id = ?
|
||||||
AND resource_type = 'relay'
|
AND resource_type = 'relay'
|
||||||
AND plan_id IS NOT NULL
|
|
||||||
AND created_at < ?
|
AND created_at < ?
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 1",
|
LIMIT 1",
|
||||||
@@ -188,26 +190,25 @@ pub async fn list_billable_activity_for_tenant(tenant_pubkey: &str) -> Result<Ve
|
|||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A tenant's relay status/plan activity strictly before `before`, oldest-first
|
/// The relay's most recent activity strictly before `before`, or `None` if it
|
||||||
/// — folded by billing to reconstruct each relay's state as of a period boundary.
|
/// had no activity yet — i.e. the relay didn't exist at that point. Billing
|
||||||
|
/// reads its snapshot to recover the relay's state as of a period boundary.
|
||||||
/// Strict `<` so a relay created exactly at the boundary isn't counted active
|
/// Strict `<` so a relay created exactly at the boundary isn't counted active
|
||||||
/// there (its own creation charge covers that period).
|
/// there (its own creation charge covers that period).
|
||||||
pub async fn list_relay_activity_before(
|
pub async fn get_latest_relay_activity_before(
|
||||||
tenant_pubkey: &str,
|
relay_id: &str,
|
||||||
before: i64,
|
before: i64,
|
||||||
) -> Result<Vec<Activity>> {
|
) -> Result<Option<Activity>> {
|
||||||
Ok(sqlx::query_as::<_, Activity>(&select_activity(
|
Ok(sqlx::query_as::<_, Activity>(&select_activity(
|
||||||
"WHERE tenant_pubkey = ?
|
"WHERE resource_id = ?
|
||||||
AND resource_type = 'relay'
|
AND resource_type = 'relay'
|
||||||
AND activity_type IN (
|
|
||||||
'create_relay', 'update_relay', 'activate_relay', 'deactivate_relay'
|
|
||||||
)
|
|
||||||
AND created_at < ?
|
AND created_at < ?
|
||||||
ORDER BY created_at ASC",
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1",
|
||||||
))
|
))
|
||||||
.bind(tenant_pubkey)
|
.bind(relay_id)
|
||||||
.bind(before)
|
.bind(before)
|
||||||
.fetch_all(pool())
|
.fetch_optional(pool())
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ pub async fn list_plans(State(_api): State<Arc<Api>>) -> ApiResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_plan(State(_api): State<Arc<Api>>, Path(id): Path<String>) -> ApiResult {
|
pub async fn get_plan(State(_api): State<Arc<Api>>, Path(id): Path<String>) -> ApiResult {
|
||||||
match query::get_plan(&id) {
|
let plan = query::get_plan(&id).map_err(|_| not_found("plan not found"))?;
|
||||||
Some(plan) => ok(plan),
|
ok(plan)
|
||||||
None => Err(not_found("plan not found")),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,8 +194,7 @@ pub async fn update_relay(
|
|||||||
.is_some_and(|requested| requested != current_plan);
|
.is_some_and(|requested| requested != current_plan);
|
||||||
|
|
||||||
if plan_changed {
|
if plan_changed {
|
||||||
let selected_plan =
|
let selected_plan = query::get_plan(&relay.plan_id).map_err(internal)?;
|
||||||
query::get_plan(&relay.plan_id).expect("validated plan must exist");
|
|
||||||
if let Some(limit) = selected_plan.members {
|
if let Some(limit) = selected_plan.members {
|
||||||
let current_members = fetch_relay_members(&relay)
|
let current_members = fetch_relay_members(&relay)
|
||||||
.await
|
.await
|
||||||
@@ -288,7 +287,7 @@ fn prepare_relay(mut relay: Relay) -> Result<Relay, ApiError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let plan = query::get_plan(&relay.plan_id)
|
let plan = query::get_plan(&relay.plan_id)
|
||||||
.ok_or_else(|| unprocessable("invalid-plan", "plan not found"))?;
|
.map_err(|_| unprocessable("invalid-plan", "plan not found"))?;
|
||||||
|
|
||||||
if (!plan.blossom && relay.blossom_enabled == 1) || (!plan.livekit && relay.livekit_enabled == 1) {
|
if (!plan.blossom && relay.blossom_enabled == 1) || (!plan.livekit && relay.livekit_enabled == 1) {
|
||||||
return Err(unprocessable("premium-feature", "feature requires a paid plan"));
|
return Err(unprocessable("premium-feature", "feature requires a paid plan"));
|
||||||
|
|||||||
Reference in New Issue
Block a user