From 6d267ed3398c7001a73a7b6ae558b5a0515c0077 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Wed, 20 May 2026 11:16:02 -0700 Subject: [PATCH] Remove relay subscription item column --- backend/migrations/0001_init.sql | 1 - backend/spec/billing.md | 5 ++-- backend/spec/command.md | 10 ------- backend/spec/models.md | 1 - backend/spec/stripe.md | 3 +- backend/src/billing.rs | 51 +++----------------------------- backend/src/command.rs | 21 ------------- backend/src/models.rs | 2 -- backend/src/stripe.rs | 12 +++----- frontend/src/lib/api.ts | 1 - 10 files changed, 11 insertions(+), 96 deletions(-) diff --git a/backend/migrations/0001_init.sql b/backend/migrations/0001_init.sql index 5e87acf..2bcf51b 100644 --- a/backend/migrations/0001_init.sql +++ b/backend/migrations/0001_init.sql @@ -23,7 +23,6 @@ CREATE TABLE IF NOT EXISTS relay ( schema TEXT NOT NULL, subdomain TEXT NOT NULL UNIQUE, plan TEXT NOT NULL, - stripe_subscription_item_id TEXT, status TEXT NOT NULL, synced INTEGER NOT NULL DEFAULT 0, sync_error TEXT NOT NULL DEFAULT '', diff --git a/backend/spec/billing.md b/backend/spec/billing.md index 199aa78..3007b75 100644 --- a/backend/spec/billing.md +++ b/backend/spec/billing.md @@ -27,16 +27,15 @@ Members: Reconciles a tenant's single Stripe subscription with the set of relays that should be billed. Only paid (non-free) relays interact with Stripe. Free-only tenants have no subscription. Must be idempotent. -Stripe forbids two subscription items on the same subscription from sharing a price, so billing is modeled as **one subscription item per plan (price), with `quantity` equal to the number of the tenant's `active` relays on that plan**. Every such relay's `stripe_subscription_item_id` points at the shared item for its plan; relays that aren't billed (free, inactive, delinquent) have it cleared. +Stripe forbids two subscription items on the same subscription from sharing a price, so billing is modeled as **one subscription item per plan (price), with `quantity` equal to the number of the tenant's `active` relays on that plan**. The relay → item mapping is fully derivable from `relay.plan → plan.stripe_price_id` and the live subscription's items, so we don't persist it on the relay. Stripe uses **pay-in-advance** by default: when a subscription is first created, Stripe immediately generates an open invoice for the current period. The `invoice.created` webhook fires shortly after and `handle_invoice_created` attempts payment. - Fetch the tenant and its relays. Build the desired state: for each `active` relay on a paid plan with a non-empty `stripe_price_id`, count relays per price. - **Resolve the live subscription**: if the tenant has a `stripe_subscription_id`, fetch it. If Stripe no longer knows about it, or its status is `canceled`/`incomplete_expired`, call `command.clear_tenant_subscription` and treat the tenant as having no subscription. -- **No relays to bill** (desired state empty): if the tenant still has a `stripe_subscription_id`, cancel the Stripe subscription and call `command.clear_tenant_subscription`. Clear `stripe_subscription_item_id` on every relay that has one. Return. +- **No relays to bill** (desired state empty): if the tenant still has a `stripe_subscription_id`, cancel the Stripe subscription and call `command.clear_tenant_subscription`. Return. - **No subscription yet**: create a Stripe subscription for the customer with `collection_method: "charge_automatically"` and one item per `(price, quantity)`. Save the subscription ID via `command.set_tenant_subscription`. - **Existing subscription**: fetch its current items. For each desired `(price, quantity)`: update the matching item's quantity if it differs, otherwise create the item. Delete any item whose price no longer appears in the desired state. -- **Point relays at items**: for each relay, set `stripe_subscription_item_id` (via `command.set_relay_subscription_item`) to the shared item for its plan, or clear it (via `command.delete_relay_subscription_item`) if the relay is not billed. - **Downgrade validation**: if any quantity decreased or any item was removed, preview the upcoming Stripe invoice and log proration line/amount details to validate expected credit/proration behavior. ## `pub fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()>` diff --git a/backend/spec/command.md b/backend/spec/command.md index 503de01..a97ce89 100644 --- a/backend/spec/command.md +++ b/backend/spec/command.md @@ -68,16 +68,6 @@ Notes: - Sets `synced = 1`, clears `sync_error` - Logs activity as `(complete_relay_sync, relay_id)` -## `pub fn delete_relay_subscription_item(&self, relay_id: &str) -> Result<()>` - -- Sets `stripe_subscription_item_id = null` -- Does not log activity - -## `pub fn set_relay_subscription_item(&self, relay_id: &str, stripe_subscription_item_id: &str) -> Result<()>` - -- Sets `stripe_subscription_item_id` -- Does not log activity - ## `pub fn set_tenant_subscription(&self, pubkey: &str, stripe_subscription_id: &str) -> Result<()>` - Sets `stripe_subscription_id` on the tenant diff --git a/backend/spec/models.md b/backend/spec/models.md index 4cb452c..ab9dcd4 100644 --- a/backend/spec/models.md +++ b/backend/spec/models.md @@ -68,7 +68,6 @@ A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid in - `schema` - the relay's db schema (read only, same as `id`) - `subdomain` - the relay's subdomain - `plan` - the relay's plan -- `stripe_subscription_item_id` (nullable) - the Stripe subscription item id. Only set for relays on paid plans. - `status` - one of `active|inactive|delinquent`. Only `active` relays count toward billing. `delinquent` is set by the billing system when a relay's subscription becomes past due; `inactive` is set when a user or admin manually deactivates a relay. - `synced` - whether the relay has been successfully synced to zooid at least once. - `sync_error` - a string indicating any errors encountered when synchronizing. diff --git a/backend/spec/stripe.md b/backend/spec/stripe.md index 2245105..e029606 100644 --- a/backend/spec/stripe.md +++ b/backend/spec/stripe.md @@ -37,11 +37,10 @@ Constructs the client with a fresh `reqwest::Client` from explicit keys (does no - Idempotent on the customer and the `(price, quantity)` set - Returns the subscription id and a map from price id to the created subscription item id -## `pub async fn create_subscription_item(&self, subscription_id: &str, price_id: &str, quantity: i64) -> Result` +## `pub async fn create_subscription_item(&self, subscription_id: &str, price_id: &str, quantity: i64) -> Result<()>` - `POST /v1/subscription_items` - Idempotent on `(subscription_id, price_id)` -- Returns the new subscription item id ## `pub async fn set_subscription_item_quantity(&self, item_id: &str, quantity: i64) -> Result<()>` diff --git a/backend/src/billing.rs b/backend/src/billing.rs index c652da0..16220a4 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -125,9 +125,6 @@ impl Billing { /// Stripe forbids two subscription items on the same subscription from sharing a /// price, so billing is modeled as one subscription item per plan (price) with /// `quantity` equal to the number of the tenant's `active` relays on that plan. - /// Every such relay's `stripe_subscription_item_id` points at the shared item for - /// its plan; relays that aren't billed (free, inactive, delinquent) have it - /// cleared. Must be idempotent. async fn sync_tenant_subscription(&self, tenant_pubkey: &str) -> Result<()> { let Some(mut tenant) = self.query.get_tenant(tenant_pubkey).await? else { return Ok(()); @@ -135,9 +132,8 @@ impl Billing { let relays = self.query.list_relays_for_tenant(tenant_pubkey).await?; - // Desired billed state: price id -> quantity, plus which relays map to which price. + // Desired billed state: price id -> quantity. let mut desired: BTreeMap = BTreeMap::new(); - let mut relay_price: BTreeMap = BTreeMap::new(); for relay in &relays { if relay.status != RELAY_STATUS_ACTIVE { continue; @@ -153,8 +149,7 @@ impl Billing { tracing::warn!(relay = %relay.id, plan = %relay.plan, "active relay on a paid plan with no configured Stripe price id; not billed"); continue; } - *desired.entry(price_id.clone()).or_insert(0) += 1; - relay_price.insert(relay.id.clone(), price_id); + *desired.entry(price_id).or_insert(0) += 1; } // Resolve the live subscription, dropping a stale reference to one that no @@ -183,19 +178,10 @@ impl Billing { .clear_tenant_subscription(tenant_pubkey) .await?; } - for relay in &relays { - if relay.stripe_subscription_item_id.is_some() { - self.command - .clear_relay_subscription_item(&relay.id) - .await?; - } - } return Ok(()); } - // Bring the subscription's items in line with `desired`. `price_to_item` ends - // up mapping every desired price to its (possibly newly created) item id. - let mut price_to_item: BTreeMap = BTreeMap::new(); + // Bring the subscription's items in line with `desired`. let mut downgraded = false; match subscription { @@ -207,9 +193,6 @@ impl Billing { self.command .set_tenant_subscription(tenant_pubkey, &sub.id) .await?; - for item in sub.items { - price_to_item.insert(item.price.id, item.id); - } tenant.stripe_subscription_id = Some(sub.id); } Some(sub) => { @@ -231,13 +214,10 @@ impl Billing { .set_subscription_item_quantity(&item_id, quantity) .await?; } - price_to_item.insert(price_id.clone(), item_id); } else { - let item_id = self - .stripe + self.stripe .create_subscription_item(&subscription_id, price_id, quantity) .await?; - price_to_item.insert(price_id.clone(), item_id); } } @@ -249,29 +229,6 @@ impl Billing { } } - // Point each relay at the shared item for its plan (or clear it if unbilled). - for relay in &relays { - match relay_price.get(&relay.id) { - Some(price_id) => { - let item_id = price_to_item - .get(price_id) - .ok_or_else(|| anyhow!("missing subscription item for price {price_id}"))?; - if relay.stripe_subscription_item_id.as_deref() != Some(item_id.as_str()) { - self.command - .set_relay_subscription_item(&relay.id, item_id) - .await?; - } - } - None => { - if relay.stripe_subscription_item_id.is_some() { - self.command - .clear_relay_subscription_item(&relay.id) - .await?; - } - } - } - } - if downgraded { self.validate_downgrade_proration(&tenant, "tenant-subscription-sync") .await; diff --git a/backend/src/command.rs b/backend/src/command.rs index 18d6644..4079a71 100644 --- a/backend/src/command.rs +++ b/backend/src/command.rs @@ -296,27 +296,6 @@ impl Command { .await } - pub async fn clear_relay_subscription_item(&self, relay_id: &str) -> Result<()> { - sqlx::query("UPDATE relay SET stripe_subscription_item_id = NULL WHERE id = ?") - .bind(relay_id) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn set_relay_subscription_item( - &self, - relay_id: &str, - stripe_subscription_item_id: &str, - ) -> Result<()> { - sqlx::query("UPDATE relay SET stripe_subscription_item_id = ? WHERE id = ?") - .bind(stripe_subscription_item_id) - .bind(relay_id) - .execute(&self.pool) - .await?; - Ok(()) - } - // Invoices pub async fn insert_pending_invoice_nwc_payment( diff --git a/backend/src/models.rs b/backend/src/models.rs index d7c4037..c9a3f5c 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -43,7 +43,6 @@ pub struct Relay { pub schema: String, pub subdomain: String, pub plan: String, - pub stripe_subscription_item_id: Option, pub status: String, pub sync_error: String, pub info_name: String, @@ -67,7 +66,6 @@ impl Default for Relay { schema: String::new(), subdomain: String::new(), plan: String::new(), - stripe_subscription_item_id: None, status: RELAY_STATUS_ACTIVE.to_string(), sync_error: String::new(), info_name: String::new(), diff --git a/backend/src/stripe.rs b/backend/src/stripe.rs index b582611..6e6d239 100644 --- a/backend/src/stripe.rs +++ b/backend/src/stripe.rs @@ -211,10 +211,9 @@ impl Stripe { subscription_id: &str, price_id: &str, quantity: i64, - ) -> Result { + ) -> Result<()> { let quantity = quantity.to_string(); - let body = self - .post("/subscription_items") + self.post("/subscription_items") .header( "Idempotency-Key", self.idempotency_key(&["create_subscription_item", subscription_id, price_id]), @@ -224,12 +223,9 @@ impl Stripe { ("price", price_id), ("quantity", quantity.as_str()), ]) - .send_json() + .send_ok() .await?; - body["id"] - .as_str() - .map(str::to_string) - .ok_or_else(|| anyhow!("missing subscription item id")) + Ok(()) } pub async fn set_subscription_item_quantity(&self, item_id: &str, quantity: i64) -> Result<()> { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7ff8765..db1f847 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -51,7 +51,6 @@ export type Relay = { plan: PlanId status: string sync_error: string - stripe_subscription_item_id: string | null synced: number info_name: string info_icon: string