Significant refactor of activity reconciliation

This commit is contained in:
Jon Staab
2026-05-27 14:16:21 -07:00
parent 7a2baf6f82
commit f37bb55286
7 changed files with 488 additions and 212 deletions
+273 -87
View File
@@ -1,4 +1,5 @@
use anyhow::{Result, anyhow};
use std::collections::HashMap;
use std::time::Duration;
use crate::bitcoin;
@@ -93,30 +94,23 @@ impl Billing {
Ok(())
}
/// If `tenant`'s subscription has rolled into a new billing period, claim it by
/// atomically recording an `autogenerate_invoice` activity, then turn that into an invoice.
/// Poll entry point: generate the tenant's invoice for the current period
/// (adding any due renewals) and, if one results, collect payment.
async fn autogenerate_invoice(&self, tenant: &Tenant) -> Result<()> {
// A subscription only exists once a billing anchor is set; until then
// there is no schedule to renew against.
let Some(billing_anchor) = tenant.billing_anchor else {
return Ok(());
};
let now = chrono::Utc::now().timestamp();
let period_start = period_start_at(billing_anchor, now);
command::try_autogenerate_invoice(&tenant.pubkey, period_start).await?;
if let Some(invoice) = self.generate_invoice(tenant).await? {
self.attempt_payment(tenant, &invoice).await?;
}
Ok(())
}
async fn handle_activity(&self, activity: &Activity) -> Result<()> {
let should_sync = matches!(
let should_reconcile = matches!(
activity.activity_type.as_str(),
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay" | "autogenerate_invoice"
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay"
);
if should_sync
if should_reconcile
&& let Some(tenant) = query::get_tenant(&activity.tenant).await?
{
self.reconcile_subscription(&tenant).await?;
@@ -148,93 +142,205 @@ impl Billing {
Ok(())
}
// --- Invoice generation and autopayment ---
// --- Reconciliation, renewal, and on-demand billing ---
/// Scan a tenant's activity for changes not yet reflected in an invoice and,
/// if there are any, create an invoice with the corresponding line items and
/// attempt to collect payment.
async fn reconcile_subscription(&self, tenant: &Tenant) -> Result<()> {
let now = chrono::Utc::now().timestamp();
let invoice_id = uuid::Uuid::new_v4().to_string();
let mut tenant = tenant.clone();
let activities = query::list_billable_activity_for_tenant(&tenant.pubkey).await?;
let billed_activity_ids: Vec<String> = activities.iter().map(|a| a.id.clone()).collect();
let mut invoice_items: Vec<InvoiceItem> = Vec::new();
for activity in &activities {
// TODO: this is gross
let relay = if activity.resource_type == "relay" {
query::get_relay(&activity.resource_id).await?
} else {
None
};
match activity.activity_type.as_str() {
"create_relay" => {
if let Some(relay) = &relay
&& let Some(plan) = query::get_plan(&relay.plan)
&& plan.amount > 0
{
// TODO: prorate amount based on billing anchor
invoice_items.push(InvoiceItem {
id: uuid::Uuid::new_v4().to_string(),
invoice_id: invoice_id.clone(),
activity_id: activity.id.clone(),
tenant_pubkey: tenant.pubkey.clone(),
relay_id: activity.resource_id.clone(),
plan: plan.id,
amount: plan.amount,
description: "New relay created".to_string(),
created_at: activity.created_at,
});
}
}
"update_relay" => {
// TODO: refund/charge prorated amount
}
"activate_relay" => {
// TODO: charge prorated amount
}
"deactivate_relay" => {
// TODO: refund prorated amount
}
"autogenerate_invoice" => {
// TODO: we're at the beginning of a new period, add invoice
// items for all active/paid relays
}
_ => {}
for activity in query::list_billable_activity_for_tenant(&tenant.pubkey).await? {
if tenant.billing_anchor.is_none() {
tenant.billing_anchor = Some(activity.created_at);
command::set_tenant_billing_anchor(&tenant).await?;
}
self.reconcile_activity(&tenant, &activity).await?;
}
// No line items (e.g. only free-plan or not-yet-prorated changes): still
// stamp the activities billed so a recovery pass doesn't re-scan them.
if invoice_items.is_empty() {
command::mark_activities_billed(&billed_activity_ids).await?;
return Ok(());
Ok(())
}
/// Reconcile one activity into the ledger: build its line item (if any) and
/// persist it with the activity's billed marker. Activities that produce no
/// item (e.g. free-plan changes) are still marked billed so they aren't
/// re-scanned.
async fn reconcile_activity(&self, tenant: &Tenant, activity: &Activity) -> Result<()> {
let invoice_item = match activity.activity_type.as_str() {
"create_relay" => {
self.make_prorated_item(tenant, activity, 1, "New relay created")
.await?
}
"activate_relay" => {
self.make_prorated_item(tenant, activity, 1, "Relay reactivated")
.await?
}
"deactivate_relay" => {
self.make_prorated_item(tenant, activity, -1, "Relay deactivated (prorated credit)")
.await?
}
"update_relay" => self.make_plan_change_item(tenant, activity).await?,
_ => None,
};
match invoice_item {
Some(item) => command::insert_invoice_item_for_activity(&item, &activity.id).await,
None => command::mark_activity_billed(&activity.id).await,
}
}
/// A prorated charge (or credit, with `sign` = -1) for the relay's current
/// plan. `None` for a missing relay or a free plan. Mid-period items don't
/// stamp `period_start` — the renewal decides coverage from activity history.
async fn make_prorated_item(
&self,
tenant: &Tenant,
activity: &Activity,
sign: i64,
description: &str,
) -> Result<Option<InvoiceItem>> {
let Some(relay) = query::get_relay(&activity.resource_id).await? else {
return Ok(None);
};
let Some(plan) = query::get_plan(&relay.plan) else {
return Ok(None);
};
if plan.amount <= 0 {
return Ok(None);
}
let period_start = invoice_items
.iter()
.map(|item| item.created_at)
.max()
.unwrap_or(now);
let anchor = tenant
.billing_anchor
.ok_or_else(|| anyhow!("billing anchor must be set before building prorated items"))?;
let fraction = period_fraction_remaining(anchor, activity.created_at);
let amount = sign * prorate(plan.amount, fraction);
Ok(Some(line_item(activity, &relay.id, plan.id, amount, description, None)))
}
/// The prorated delta for a plan change, read straight from the activity log:
/// `new` is this `update_relay` activity's recorded plan, `old` is the relay's
/// plan immediately before it. Because the renewal charges the relay's plan as
/// of the period boundary, this delta composes to the correct total regardless
/// of ordering and needs no coverage gate. `None` when nothing changed.
async fn make_plan_change_item(
&self,
tenant: &Tenant,
activity: &Activity,
) -> Result<Option<InvoiceItem>> {
let Some(new_plan_id) = activity.plan_id.as_deref() else {
return Ok(None);
};
let Some(old_plan_id) =
query::get_relay_plan_before(&activity.resource_id, activity.created_at).await?
else {
return Ok(None);
};
if old_plan_id == new_plan_id {
return Ok(None);
}
let (Some(new_plan), Some(old_plan)) =
(query::get_plan(new_plan_id), query::get_plan(&old_plan_id))
else {
return Ok(None);
};
let anchor = tenant
.billing_anchor
.ok_or_else(|| anyhow!("billing anchor must be set before building prorated items"))?;
let fraction = period_fraction_remaining(anchor, activity.created_at);
let amount = prorate(new_plan.amount, fraction) - prorate(old_plan.amount, fraction);
if amount == 0 {
return Ok(None);
}
let description = format!("Plan changed from {} to {}", old_plan.name, new_plan.name);
Ok(Some(line_item(
activity,
&activity.resource_id,
new_plan.id,
amount,
&description,
None,
)))
}
/// Reconcile pending activity, add this period's renewals for any relay due,
/// and claim everything outstanding onto an invoice. Shared by the poll and
/// the on-demand invoice endpoint — safe to call either way: renewals are
/// per-relay idempotent. No payment is attempted here; callers that want
/// auto-pay do it on the returned invoice. `None` when nothing is owed.
pub async fn generate_invoice(&self, tenant: &Tenant) -> Result<Option<Invoice>> {
self.reconcile_subscription(tenant).await?;
// reconcile may have just set the anchor (first activity); re-read it.
let Some(tenant) = query::get_tenant(&tenant.pubkey).await? else {
return Ok(None);
};
let Some(anchor) = tenant.billing_anchor else {
return Ok(None);
};
let now = chrono::Utc::now().timestamp();
let period_start = period_start_at(anchor, now);
let period_end = add_one_month(period_start);
let invoice = command::create_invoice(
self.renew_period(&tenant, period_start).await?;
self.claim_outstanding(&tenant, period_start, period_end).await
}
/// 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
/// (status from create/activate/deactivate, plan from create/update). Per-relay
/// idempotent via `period_start`, so calling it on every generation can't
/// renew a relay twice; a relay created/activated *within* the period isn't
/// active before the boundary, so it's covered by its own prorated charge.
async fn renew_period(&self, tenant: &Tenant, period_start: i64) -> Result<()> {
let activities = query::list_relay_activity_before(&tenant.pubkey, period_start).await?;
let mut renewal_items = Vec::new();
for (relay_id, state) in relay_states(&activities) {
if !state.active {
continue;
}
let Some(plan) = state.plan.and_then(|id| query::get_plan(&id)) else {
continue;
};
if plan.amount <= 0 {
continue;
}
renewal_items.push(InvoiceItem {
id: uuid::Uuid::new_v4().to_string(),
invoice_id: None,
activity_id: None,
tenant_pubkey: tenant.pubkey.clone(),
relay_id,
plan: plan.id,
amount: plan.amount,
description: "Subscription renewal".to_string(),
created_at: period_start,
period_start: Some(period_start),
});
}
command::create_renewal_items(&renewal_items).await
}
/// Claim the tenant's outstanding items onto a fresh invoice if they net
/// positive; `None` when nothing is owed (a net credit stays outstanding and
/// carries to the next positive invoice).
async fn claim_outstanding(
&self,
tenant: &Tenant,
period_start: i64,
period_end: i64,
) -> Result<Option<Invoice>> {
let invoice_id = uuid::Uuid::new_v4().to_string();
command::claim_outstanding_into_invoice(
&invoice_id,
&tenant.pubkey,
period_start,
period_end,
&invoice_items,
&billed_activity_ids,
tenant.billing_anchor.is_none().then_some(now),
)
.await?;
self.attempt_payment(tenant, &invoice).await?;
Ok(())
.await
}
pub async fn attempt_payment(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> {
@@ -428,6 +534,86 @@ fn add_one_month(ts: i64) -> i64 {
.unwrap_or(ts)
}
/// Fraction of the current billing period still unused at `at`, in `[0.0, 1.0]`,
/// for prorating a mid-period charge or credit. With no billing anchor yet the
/// period is only just beginning, so the whole period remains (full price).
fn period_fraction_remaining(billing_anchor: i64, at: i64) -> f64 {
let period_start = period_start_at(billing_anchor, at);
let period_end = add_one_month(period_start);
let period_len = (period_end - period_start) as f64;
if period_len <= 0.0 {
return 1.0;
}
(((period_end - at) as f64) / period_len).clamp(0.0, 1.0)
}
/// Prorate a minor-unit `amount` by `fraction`, rounded to the nearest unit.
fn prorate(amount: i64, fraction: f64) -> i64 {
(amount as f64 * fraction).round() as i64
}
/// Build an outstanding (unassigned, `invoice_id = None`) line item from a
/// reconciled activity. `period_start` is `Some` only for coverage charges
/// (creation/activation), which mark the relay-period as paid.
fn line_item(
activity: &Activity,
relay_id: &str,
plan: String,
amount: i64,
description: &str,
period_start: Option<i64>,
) -> InvoiceItem {
InvoiceItem {
id: uuid::Uuid::new_v4().to_string(),
invoice_id: None,
activity_id: Some(activity.id.clone()),
tenant_pubkey: activity.tenant.clone(),
relay_id: relay_id.to_string(),
plan,
amount,
description: description.to_string(),
created_at: activity.created_at,
period_start,
}
}
/// A relay's billing-relevant state at a point in time, reconstructed by folding
/// its activity log.
#[derive(Default)]
struct RelayState {
active: bool,
plan: 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 = activity.plan_id.clone();
}
"update_relay" => {
if activity.plan_id.is_some() {
state.plan = activity.plan_id.clone();
}
}
"activate_relay" => state.active = true,
"deactivate_relay" => state.active = false,
_ => {}
}
}
states
}
fn summarize_error_message(error: &str) -> Option<String> {
let normalized = error.split_whitespace().collect::<Vec<_>>().join(" ");
if normalized.is_empty() {