From cd70ca665429c4fbe26a2ce39bb4a9e27a8f2264 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Wed, 27 May 2026 15:35:02 -0700 Subject: [PATCH] Move renewed_at to tenant --- backend/migrations/0001_init.sql | 6 ++-- backend/src/billing.rs | 18 ++++++----- backend/src/command.rs | 52 ++++++++++++++++++-------------- backend/src/models.rs | 6 ++-- 4 files changed, 45 insertions(+), 37 deletions(-) diff --git a/backend/migrations/0001_init.sql b/backend/migrations/0001_init.sql index 264e380..ae8f81e 100644 --- a/backend/migrations/0001_init.sql +++ b/backend/migrations/0001_init.sql @@ -15,7 +15,8 @@ CREATE TABLE IF NOT EXISTS tenant ( nwc_error TEXT, created_at INTEGER NOT NULL, billing_anchor INTEGER, - stripe_customer_id TEXT NOT NULL + stripe_customer_id TEXT NOT NULL, + renewed_at INTEGER ); CREATE TABLE IF NOT EXISTS relay ( @@ -61,7 +62,6 @@ CREATE TABLE IF NOT EXISTS invoice_item ( amount INTEGER NOT NULL, description TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, - period_start INTEGER, FOREIGN KEY (invoice_id) REFERENCES invoice(id), FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey) ); @@ -98,8 +98,6 @@ CREATE INDEX IF NOT EXISTS idx_invoice_item_invoice ON invoice_item (invoice_id) CREATE INDEX IF NOT EXISTS idx_invoice_item_outstanding ON invoice_item (tenant_pubkey) WHERE invoice_id IS NULL; -CREATE INDEX IF NOT EXISTS idx_invoice_item_renewal ON invoice_item (tenant_pubkey, period_start); - 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); diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 0066ecd..487770a 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -213,7 +213,7 @@ impl Billing { 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))) + Ok(Some(line_item(activity, &relay.id, plan.id, amount, description))) } /// The prorated delta for a plan change, read straight from the activity log: @@ -259,7 +259,6 @@ impl Billing { new_plan.id, amount, &description, - None, ))) } @@ -283,7 +282,13 @@ impl Billing { let period_start = period_start_at(anchor, now); let period_end = add_one_month(period_start); - self.renew_period(&tenant, period_start).await?; + // Short-circuit the renewal scan once this period is already renewed — the + // common case on all but the first poll of a period (saving ~720 scans a + // month per tenant). 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?; + } + self.claim_outstanding(&tenant, period_start, period_end).await } @@ -317,11 +322,12 @@ impl Billing { amount: plan.amount, description: "Subscription renewal".to_string(), created_at: period_start, - period_start: Some(period_start), }); } - command::create_renewal_items(&renewal_items).await + // 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 } /// Claim the tenant's outstanding items onto a fresh invoice if they net @@ -562,7 +568,6 @@ fn line_item( plan: String, amount: i64, description: &str, - period_start: Option, ) -> InvoiceItem { InvoiceItem { id: uuid::Uuid::new_v4().to_string(), @@ -574,7 +579,6 @@ fn line_item( amount, description: description.to_string(), created_at: activity.created_at, - period_start, } } diff --git a/backend/src/command.rs b/backend/src/command.rs index efe7e4f..4d4596a 100644 --- a/backend/src/command.rs +++ b/backend/src/command.rs @@ -212,30 +212,37 @@ pub async fn mark_activity_billed(activity_id: &str) -> Result<()> { /// `period_start`. The per-relay existence check and insert are a single /// statement, so neither a re-tick nor a relay's own creation/activation charge /// (which also stamps `period_start`) can bill the same relay-period twice. -pub async fn create_renewal_items(items: &[InvoiceItem]) -> Result<()> { +pub async fn renew_tenant( + tenant_pubkey: &str, + period_start: i64, + items: &[InvoiceItem], +) -> Result<()> { with_tx(async |tx| { + // In-tx guard: bail if this tenant has already been renewed for this + // period (or later). This is the correctness backstop — it keeps renewal + // idempotent under a crash mid-renewal or a poll racing the eager + // endpoint, since the item inserts and the `renewed_at` write commit + // together. + 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 { - sqlx::query( - "INSERT INTO invoice_item - (id, invoice_id, activity_id, tenant_pubkey, relay_id, plan, amount, description, created_at, period_start) - SELECT ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ? - WHERE NOT EXISTS ( - SELECT 1 FROM invoice_item WHERE relay_id = ? AND period_start = ? - )", - ) - .bind(&item.id) - .bind(&item.tenant_pubkey) - .bind(&item.relay_id) - .bind(&item.plan) - .bind(item.amount) - .bind(&item.description) - .bind(item.created_at) - .bind(item.period_start) - .bind(&item.relay_id) - .bind(item.period_start) + 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(()) }) @@ -433,8 +440,8 @@ async fn insert_invoice_tx( async fn insert_invoice_item_tx(tx: &mut Transaction<'_, Sqlite>, item: &InvoiceItem) -> Result<()> { sqlx::query( "INSERT INTO invoice_item - (id, invoice_id, activity_id, tenant_pubkey, relay_id, plan, amount, description, created_at, period_start) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (id, invoice_id, activity_id, tenant_pubkey, relay_id, plan, amount, description, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&item.id) .bind(&item.invoice_id) @@ -445,7 +452,6 @@ async fn insert_invoice_item_tx(tx: &mut Transaction<'_, Sqlite>, item: &Invoice .bind(item.amount) .bind(&item.description) .bind(item.created_at) - .bind(item.period_start) .execute(&mut **tx) .await?; Ok(()) diff --git a/backend/src/models.rs b/backend/src/models.rs index 061e88f..7bce69d 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -36,6 +36,9 @@ pub struct Tenant { pub created_at: i64, pub billing_anchor: Option, pub stripe_customer_id: String, + /// `period_start` of the most recent period this tenant was renewed for, or + /// `None` if never renewed. The per-period renewal idempotency marker. + pub renewed_at: Option, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] @@ -107,9 +110,6 @@ pub struct InvoiceItem { pub amount: i64, pub description: String, pub created_at: i64, - /// Set only on renewal items: the billing period this item renews. Doubles as - /// the marker that prevents a period from being renewed twice. - pub period_start: Option, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]