From 72b30489b9be60d855d34ee51af8c766ed0d1f14 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Thu, 28 May 2026 13:17:06 -0700 Subject: [PATCH] Add BillingPeriod helper --- backend/src/billing.rs | 187 ++++++++++++++++++++--------------------- backend/src/command.rs | 78 +++++++++-------- 2 files changed, 133 insertions(+), 132 deletions(-) diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 6636a7a..66d55b9 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -147,6 +147,8 @@ impl Billing { // --- Reconciliation, renewal, and on-demand billing --- + /// Lists billable activity, setting the tenant's billing anchor to the first + /// activity in the process. async fn reconcile_subscription(&self, tenant: &Tenant) -> Result<()> { let mut tenant = tenant.clone(); @@ -210,13 +212,21 @@ impl Billing { return Ok(None); } - let anchor = tenant - .billing_anchor + let period = BillingPeriod::new(tenant, activity.created_at) .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); + let amount = sign * period.prorate(plan.amount, activity.created_at); - Ok(Some(line_item(activity, &relay.id, plan.id, amount, description))) + Ok(Some(InvoiceItem { + id: uuid::Uuid::new_v4().to_string(), + invoice_id: None, + activity_id: Some(activity.id.clone()), + tenant_pubkey: activity.tenant.clone(), + relay_id: activity.resource_id.clone(), + plan: plan.id, + amount, + description: description.to_string(), + created_at: activity.created_at, + })) } /// The prorated delta for a plan change, read straight from the activity log: @@ -246,23 +256,26 @@ impl Billing { return Ok(None); }; - let anchor = tenant - .billing_anchor + let period = BillingPeriod::new(tenant, activity.created_at) .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); + let amount = period.prorate(new_plan.amount, activity.created_at) + - period.prorate(old_plan.amount, activity.created_at); 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, + Ok(Some(InvoiceItem { + id: uuid::Uuid::new_v4().to_string(), + invoice_id: None, + activity_id: Some(activity.id.clone()), + tenant_pubkey: activity.tenant.clone(), + relay_id: activity.resource_id.clone(), + plan: new_plan.id, amount, - &description, - ))) + description, + created_at: activity.created_at, + })) } /// Reconcile pending activity, add this period's renewals if they're due, and @@ -274,37 +287,30 @@ impl Billing { let Some(tenant) = query::get_tenant(&tenant.pubkey).await? else { return Ok(None); }; - let Some(anchor) = tenant.billing_anchor else { + let Some(period) = BillingPeriod::current(&tenant) 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); - // Short-circuit the renewal scan if this period is already renewed, for // performance. Renew_tenant re-checks this in-tx as the real guard. - if tenant.renewed_at.is_none_or(|at| at < period_start) { - self.renew_period(&tenant, period_start).await?; + if tenant.renewed_at.is_none_or(|at| at < period.start) { + self.renew_period(&tenant, &period).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). - command::create_invoice(&tenant.pubkey, period_start, period_end).await + command::create_invoice(&tenant.pubkey, 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 + /// as of `period.start`, reconstructing that state from the activity log /// (status from create/activate/deactivate, plan from create/update). /// Idempotent per period via the tenant's `renewed_at` marker, so calling it /// on every generation can't renew twice; a relay created/activated *within* /// the period isn't active before the boundary, so it's covered by its own /// prorated charge instead. - async fn renew_period(&self, tenant: &Tenant, period_start: i64) -> Result<()> { - let activities = query::list_relay_activity_before(&tenant.pubkey, period_start).await?; + async fn renew_period(&self, tenant: &Tenant, period: &BillingPeriod) -> Result<()> { + let activities = query::list_relay_activity_before(&tenant.pubkey, period.start).await?; - let mut renewal_items = Vec::new(); + let mut line_items = Vec::new(); for (relay_id, state) in relay_states(&activities) { if !state.active { continue; @@ -315,7 +321,7 @@ impl Billing { if plan.amount <= 0 { continue; } - renewal_items.push(InvoiceItem { + line_items.push(InvoiceItem { id: uuid::Uuid::new_v4().to_string(), invoice_id: None, activity_id: None, @@ -324,13 +330,13 @@ impl Billing { plan: plan.id, amount: plan.amount, description: "Subscription renewal".to_string(), - created_at: period_start, + created_at: period.start, }); } - // Inserts the items and advances `renewed_at` to `period_start` in one + // Inserts the items and advances `renewed_at` to `period.start` in one // transaction (idempotent via an in-tx guard), so a re-tick is a no-op. - command::renew_tenant(&tenant.pubkey, period_start, &renewal_items).await + command::insert_invoice_items_for_renewal(&line_items, period).await } pub async fn attempt_payment(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> { @@ -492,75 +498,64 @@ const MANUAL_PAYMENT_DM: &str = "Payment is due for your relay subscription. Ope const USER_ERROR_PREFIX: &str = "NWC auto-payment failed:"; const USER_ERROR_MAX_CHARS: usize = 240; -/// The start of the billing period containing `now`, for monthly periods -/// anchored at `anchor`. Steps forward in whole calendar months so boundaries -/// track months (28–31 days) rather than a fixed span of seconds. -fn period_start_at(anchor: i64, now: i64) -> i64 { - use chrono::{DateTime, Months, Utc}; +/// One tenant's monthly billing period containing some timestamp, anchored at +/// the tenant's `billing_anchor`. Half-open `[start, end)` so a moment at +/// exactly `end` belongs to the next period. +pub struct BillingPeriod { + pub start: i64, + pub end: i64, +} - let anchor_dt = DateTime::::from_timestamp(anchor, 0).unwrap_or_default(); +impl BillingPeriod { + /// The period containing `chrono::Utc::now()` for `tenant`. `None` when the + /// tenant has no `billing_anchor` yet — i.e. no billable activity has been seen. + fn current(tenant: &Tenant) -> Option { + Self::new(tenant, chrono::Utc::now().timestamp()) + } - let mut start = anchor_dt; - let mut months = 1u32; - while let Some(next) = anchor_dt.checked_add_months(Months::new(months)) { - if next.timestamp() > now { - break; + /// The period containing `at` for `tenant`. `None` when the tenant has no + /// `billing_anchor` yet — i.e. no billable activity has been seen. + fn new(tenant: &Tenant, at: i64) -> Option { + use chrono::{DateTime, Months, Utc}; + + let anchor = tenant.billing_anchor?; + let anchor_dt = DateTime::::from_timestamp(anchor, 0).unwrap_or_default(); + + // Walk forward in whole calendar months from the anchor until the next + // step would pass `at`, so boundaries track months (28–31 days) rather + // than a fixed span of seconds. + let mut start = anchor_dt; + let mut months = 1u32; + while let Some(next) = anchor_dt.checked_add_months(Months::new(months)) { + if next.timestamp() > at { + break; + } + start = next; + months += 1; } - start = next; - months += 1; + + let end = start.checked_add_months(Months::new(1)).unwrap_or(start); + + Some(Self { + start: start.timestamp(), + end: end.timestamp(), + }) } - start.timestamp() -} - -/// One calendar month after `ts` (a unix timestamp), falling back to `ts` if the -/// shifted date can't be represented. -fn add_one_month(ts: i64) -> i64 { - use chrono::{DateTime, Months, Utc}; - - DateTime::::from_timestamp(ts, 0) - .and_then(|dt| dt.checked_add_months(Months::new(1))) - .map(|dt| dt.timestamp()) - .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. -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; + /// Fraction of this period still unused at `at`, in `[0.0, 1.0]`, for + /// prorating a mid-period charge or credit. + fn fraction_remaining(&self, at: i64) -> f64 { + let len = (self.end - self.start) as f64; + if len <= 0.0 { + return 1.0; + } + (((self.end - at) as f64) / len).clamp(0.0, 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. -fn line_item( - activity: &Activity, - relay_id: &str, - plan: String, - amount: i64, - description: &str, -) -> 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, + /// Prorate a minor-unit `amount` by the fraction of this period remaining + /// at `at`, rounded to the nearest unit. + fn prorate(&self, amount: i64, at: i64) -> i64 { + (amount as f64 * self.fraction_remaining(at)).round() as i64 } } diff --git a/backend/src/command.rs b/backend/src/command.rs index 6d3aea9..ebec273 100644 --- a/backend/src/command.rs +++ b/backend/src/command.rs @@ -1,6 +1,7 @@ use anyhow::Result; use sqlx::{Sqlite, Transaction}; +use crate::billing::BillingPeriod; use crate::db::{pool, publish, with_tx}; use crate::models::{ Activity, Bolt11, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, @@ -59,42 +60,6 @@ pub async fn clear_tenant_nwc_error(pubkey: &str) -> Result<()> { Ok(()) } -/// Insert this period's renewal items and advance the tenant's `renewed_at` -/// marker to `period_start`, atomically and idempotently. -pub async fn renew_tenant( - tenant_pubkey: &str, - period_start: i64, - items: &[InvoiceItem], -) -> Result<()> { - with_tx(async |tx| { - // Re-read the marker inside the transaction so the guard and the writes - // commit together — this ensures idempotency so we don't double-invoice. - let renewed_at = sqlx::query_scalar::<_, Option>( - "SELECT renewed_at FROM tenant WHERE pubkey = ?", - ) - .bind(tenant_pubkey) - .fetch_one(&mut **tx) - .await?; - - if renewed_at.is_some_and(|at| at >= period_start) { - return Ok(()); - } - - for item in items { - insert_invoice_item_tx(tx, item).await?; - } - - sqlx::query("UPDATE tenant SET renewed_at = ? WHERE pubkey = ?") - .bind(period_start) - .bind(tenant_pubkey) - .execute(&mut **tx) - .await?; - - Ok(()) - }) - .await -} - // --- Relays --- pub async fn create_relay(relay: &Relay) -> Result<()> { @@ -241,6 +206,47 @@ pub async fn insert_invoice_item_for_activity(invoice_item: &InvoiceItem, activi .await } +/// Insert this period's renewal items and advance the tenant's `renewed_at` +/// marker to `period.start`, atomically and idempotently. Empty `items` is a +/// no-op — a tenant with no active paid relays has nothing to renew. +pub async fn insert_invoice_items_for_renewal( + items: &[InvoiceItem], + period: &BillingPeriod, +) -> Result<()> { + let Some(first) = items.first() else { + return Ok(()); + }; + let tenant_pubkey = &first.tenant_pubkey; + + with_tx(async |tx| { + // Re-read the marker inside the transaction so the guard and the writes + // commit together — this ensures idempotency so we don't double-invoice. + let renewed_at = sqlx::query_scalar::<_, Option>( + "SELECT renewed_at FROM tenant WHERE pubkey = ?", + ) + .bind(tenant_pubkey) + .fetch_one(&mut **tx) + .await?; + + if renewed_at.is_some_and(|at| at >= period.start) { + return Ok(()); + } + + for item in items { + insert_invoice_item_tx(tx, item).await?; + } + + sqlx::query("UPDATE tenant SET renewed_at = ? WHERE pubkey = ?") + .bind(period.start) + .bind(tenant_pubkey) + .execute(&mut **tx) + .await?; + + Ok(()) + }) + .await +} + /// Mark an activity billed without a line item — for activities that produce no /// charge (e.g. free-plan changes), so a recovery pass doesn't re-scan them. pub async fn mark_activity_billed(activity_id: &str) -> Result<()> {