forked from coracle/caravel
Significant refactor of activity reconciliation
This commit is contained in:
+273
-87
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user