Remove relay subscription item column
This commit is contained in:
@@ -23,7 +23,6 @@ CREATE TABLE IF NOT EXISTS relay (
|
|||||||
schema TEXT NOT NULL,
|
schema TEXT NOT NULL,
|
||||||
subdomain TEXT NOT NULL UNIQUE,
|
subdomain TEXT NOT NULL UNIQUE,
|
||||||
plan TEXT NOT NULL,
|
plan TEXT NOT NULL,
|
||||||
stripe_subscription_item_id TEXT,
|
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
synced INTEGER NOT NULL DEFAULT 0,
|
synced INTEGER NOT NULL DEFAULT 0,
|
||||||
sync_error TEXT NOT NULL DEFAULT '',
|
sync_error TEXT NOT NULL DEFAULT '',
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
- 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.
|
- **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`.
|
- **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.
|
- **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.
|
- **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<()>`
|
## `pub fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()>`
|
||||||
|
|||||||
@@ -68,16 +68,6 @@ Notes:
|
|||||||
- Sets `synced = 1`, clears `sync_error`
|
- Sets `synced = 1`, clears `sync_error`
|
||||||
- Logs activity as `(complete_relay_sync, relay_id)`
|
- 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<()>`
|
## `pub fn set_tenant_subscription(&self, pubkey: &str, stripe_subscription_id: &str) -> Result<()>`
|
||||||
|
|
||||||
- Sets `stripe_subscription_id` on the tenant
|
- Sets `stripe_subscription_id` on the tenant
|
||||||
|
|||||||
@@ -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`)
|
- `schema` - the relay's db schema (read only, same as `id`)
|
||||||
- `subdomain` - the relay's subdomain
|
- `subdomain` - the relay's subdomain
|
||||||
- `plan` - the relay's plan
|
- `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.
|
- `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.
|
- `synced` - whether the relay has been successfully synced to zooid at least once.
|
||||||
- `sync_error` - a string indicating any errors encountered when synchronizing.
|
- `sync_error` - a string indicating any errors encountered when synchronizing.
|
||||||
|
|||||||
@@ -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
|
- 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
|
- 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<String>`
|
## `pub async fn create_subscription_item(&self, subscription_id: &str, price_id: &str, quantity: i64) -> Result<()>`
|
||||||
|
|
||||||
- `POST /v1/subscription_items`
|
- `POST /v1/subscription_items`
|
||||||
- Idempotent on `(subscription_id, price_id)`
|
- 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<()>`
|
## `pub async fn set_subscription_item_quantity(&self, item_id: &str, quantity: i64) -> Result<()>`
|
||||||
|
|
||||||
|
|||||||
+4
-47
@@ -125,9 +125,6 @@ impl Billing {
|
|||||||
/// Stripe forbids two subscription items on the same subscription from sharing a
|
/// 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
|
/// 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.
|
/// `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<()> {
|
async fn sync_tenant_subscription(&self, tenant_pubkey: &str) -> Result<()> {
|
||||||
let Some(mut tenant) = self.query.get_tenant(tenant_pubkey).await? else {
|
let Some(mut tenant) = self.query.get_tenant(tenant_pubkey).await? else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -135,9 +132,8 @@ impl Billing {
|
|||||||
|
|
||||||
let relays = self.query.list_relays_for_tenant(tenant_pubkey).await?;
|
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<String, i64> = BTreeMap::new();
|
let mut desired: BTreeMap<String, i64> = BTreeMap::new();
|
||||||
let mut relay_price: BTreeMap<String, String> = BTreeMap::new();
|
|
||||||
for relay in &relays {
|
for relay in &relays {
|
||||||
if relay.status != RELAY_STATUS_ACTIVE {
|
if relay.status != RELAY_STATUS_ACTIVE {
|
||||||
continue;
|
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");
|
tracing::warn!(relay = %relay.id, plan = %relay.plan, "active relay on a paid plan with no configured Stripe price id; not billed");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
*desired.entry(price_id.clone()).or_insert(0) += 1;
|
*desired.entry(price_id).or_insert(0) += 1;
|
||||||
relay_price.insert(relay.id.clone(), price_id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the live subscription, dropping a stale reference to one that no
|
// Resolve the live subscription, dropping a stale reference to one that no
|
||||||
@@ -183,19 +178,10 @@ impl Billing {
|
|||||||
.clear_tenant_subscription(tenant_pubkey)
|
.clear_tenant_subscription(tenant_pubkey)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
for relay in &relays {
|
|
||||||
if relay.stripe_subscription_item_id.is_some() {
|
|
||||||
self.command
|
|
||||||
.clear_relay_subscription_item(&relay.id)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bring the subscription's items in line with `desired`. `price_to_item` ends
|
// Bring the subscription's items in line with `desired`.
|
||||||
// up mapping every desired price to its (possibly newly created) item id.
|
|
||||||
let mut price_to_item: BTreeMap<String, String> = BTreeMap::new();
|
|
||||||
let mut downgraded = false;
|
let mut downgraded = false;
|
||||||
|
|
||||||
match subscription {
|
match subscription {
|
||||||
@@ -207,9 +193,6 @@ impl Billing {
|
|||||||
self.command
|
self.command
|
||||||
.set_tenant_subscription(tenant_pubkey, &sub.id)
|
.set_tenant_subscription(tenant_pubkey, &sub.id)
|
||||||
.await?;
|
.await?;
|
||||||
for item in sub.items {
|
|
||||||
price_to_item.insert(item.price.id, item.id);
|
|
||||||
}
|
|
||||||
tenant.stripe_subscription_id = Some(sub.id);
|
tenant.stripe_subscription_id = Some(sub.id);
|
||||||
}
|
}
|
||||||
Some(sub) => {
|
Some(sub) => {
|
||||||
@@ -231,13 +214,10 @@ impl Billing {
|
|||||||
.set_subscription_item_quantity(&item_id, quantity)
|
.set_subscription_item_quantity(&item_id, quantity)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
price_to_item.insert(price_id.clone(), item_id);
|
|
||||||
} else {
|
} else {
|
||||||
let item_id = self
|
self.stripe
|
||||||
.stripe
|
|
||||||
.create_subscription_item(&subscription_id, price_id, quantity)
|
.create_subscription_item(&subscription_id, price_id, quantity)
|
||||||
.await?;
|
.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 {
|
if downgraded {
|
||||||
self.validate_downgrade_proration(&tenant, "tenant-subscription-sync")
|
self.validate_downgrade_proration(&tenant, "tenant-subscription-sync")
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
@@ -296,27 +296,6 @@ impl Command {
|
|||||||
.await
|
.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
|
// Invoices
|
||||||
|
|
||||||
pub async fn insert_pending_invoice_nwc_payment(
|
pub async fn insert_pending_invoice_nwc_payment(
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ pub struct Relay {
|
|||||||
pub schema: String,
|
pub schema: String,
|
||||||
pub subdomain: String,
|
pub subdomain: String,
|
||||||
pub plan: String,
|
pub plan: String,
|
||||||
pub stripe_subscription_item_id: Option<String>,
|
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub sync_error: String,
|
pub sync_error: String,
|
||||||
pub info_name: String,
|
pub info_name: String,
|
||||||
@@ -67,7 +66,6 @@ impl Default for Relay {
|
|||||||
schema: String::new(),
|
schema: String::new(),
|
||||||
subdomain: String::new(),
|
subdomain: String::new(),
|
||||||
plan: String::new(),
|
plan: String::new(),
|
||||||
stripe_subscription_item_id: None,
|
|
||||||
status: RELAY_STATUS_ACTIVE.to_string(),
|
status: RELAY_STATUS_ACTIVE.to_string(),
|
||||||
sync_error: String::new(),
|
sync_error: String::new(),
|
||||||
info_name: String::new(),
|
info_name: String::new(),
|
||||||
|
|||||||
@@ -211,10 +211,9 @@ impl Stripe {
|
|||||||
subscription_id: &str,
|
subscription_id: &str,
|
||||||
price_id: &str,
|
price_id: &str,
|
||||||
quantity: i64,
|
quantity: i64,
|
||||||
) -> Result<String> {
|
) -> Result<()> {
|
||||||
let quantity = quantity.to_string();
|
let quantity = quantity.to_string();
|
||||||
let body = self
|
self.post("/subscription_items")
|
||||||
.post("/subscription_items")
|
|
||||||
.header(
|
.header(
|
||||||
"Idempotency-Key",
|
"Idempotency-Key",
|
||||||
self.idempotency_key(&["create_subscription_item", subscription_id, price_id]),
|
self.idempotency_key(&["create_subscription_item", subscription_id, price_id]),
|
||||||
@@ -224,12 +223,9 @@ impl Stripe {
|
|||||||
("price", price_id),
|
("price", price_id),
|
||||||
("quantity", quantity.as_str()),
|
("quantity", quantity.as_str()),
|
||||||
])
|
])
|
||||||
.send_json()
|
.send_ok()
|
||||||
.await?;
|
.await?;
|
||||||
body["id"]
|
Ok(())
|
||||||
.as_str()
|
|
||||||
.map(str::to_string)
|
|
||||||
.ok_or_else(|| anyhow!("missing subscription item id"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_subscription_item_quantity(&self, item_id: &str, quantity: i64) -> Result<()> {
|
pub async fn set_subscription_item_quantity(&self, item_id: &str, quantity: i64) -> Result<()> {
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ export type Relay = {
|
|||||||
plan: PlanId
|
plan: PlanId
|
||||||
status: string
|
status: string
|
||||||
sync_error: string
|
sync_error: string
|
||||||
stripe_subscription_item_id: string | null
|
|
||||||
synced: number
|
synced: number
|
||||||
info_name: string
|
info_name: string
|
||||||
info_icon: string
|
info_icon: string
|
||||||
|
|||||||
Reference in New Issue
Block a user