g1t/services/billing/src/rename.rs
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 1 | //! A workspace renamed: everything billing keeps under its slug moves to |
| 2 | //! the new one. | |
| 3 | //! | |
| 4 | //! Identity publishes `workspace.renamed` with the old and new slugs. The | |
| 5 | //! handler asks identity what the workspace is called now, so that a late | |
| 6 | //! or repeated delivery still converges on the current slug, and moves the | |
| 7 | //! rows of each stale slug in one D1 batch (one transaction). | |
| 8 | //! | |
| 9 | //! Rows may already exist under the current slug: usage recorded in the | |
| 10 | //! seconds between the rename and this event. Those are merged, never | |
| 11 | //! dropped where money is concerned: balances are summed, sandbox seconds | |
| 12 | //! are summed, and owners' limits are kept field by field. | |
| 13 | ||
| 14 | use g1t_contracts::events::{Event, WorkspaceRenamed}; | |
| 15 | use g1t_contracts::identity::UsernamesArgs; | |
| 16 | use serde::Deserialize; | |
| 17 | use serde_json::Value; | |
| 18 | use worker::wasm_bindgen::JsValue; | |
| 19 | use worker::{Fetcher, Result}; | |
| 20 | ||
| 21 | use crate::Billing; | |
| 22 | use crate::accounts::own_account; | |
| 23 | ||
| 24 | /// The event this module handles. | |
| 25 | pub(crate) const RENAMED: &str = "workspace.renamed"; | |
| 26 | ||
| 27 | /// The statements that move one stale slug's rows to the current slug, in | |
| 28 | /// order. Parameters: `?1` the current slug, `?2` the stale one, `?3` and | |
| 29 | /// `?4` their own billing accounts (`ws_<slug>`). | |
| 30 | /// | |
| 31 | /// Every statement is a no-op once the stale slug has no rows, so running | |
| 32 | /// the batch again changes nothing. | |
| 33 | pub(crate) const STATEMENTS: &[&str] = &[ | |
| 34 | // Plain rows: many per workspace, keyed by their own id. | |
| 35 | "UPDATE ledger SET workspace = ?1 WHERE workspace = ?2", | |
| 36 | "UPDATE runs SET workspace = ?1 WHERE workspace = ?2", | |
| 37 | "UPDATE checkouts SET workspace = ?1 WHERE workspace = ?2", | |
| 38 | "UPDATE workspace_invoices SET workspace = ?1 WHERE workspace = ?2", | |
| 39 | "UPDATE sales_notes SET workspace = ?1 WHERE workspace = ?2", | |
| 40 | // Repositories are named `<slug>/<name>`. Compared exactly rather than | |
| 41 | // with LIKE, so nothing in a slug is read as a wildcard. | |
| 42 | "UPDATE ledger SET repo = ?1 || substr(repo, length(?2) + 1) WHERE substr(repo, 1, length(?2) + 1) = ?2 || '/'", | |
| 43 | "UPDATE runs SET repo = ?1 || substr(repo, length(?2) + 1) WHERE substr(repo, 1, length(?2) + 1) = ?2 || '/'", | |
| 44 | // The balance is the sum of the ledger, and both ledgers are now the | |
| 45 | // current slug's: the balances add. The older customer (with the card | |
| 46 | // and the payment history) is kept when both have one. | |
| 47 | "INSERT INTO accounts (workspace, balance_micros, customer_id, created_at) | |
| 48 | SELECT ?1, balance_micros, customer_id, created_at FROM accounts WHERE workspace = ?2 | |
| 49 | ON CONFLICT (workspace) DO UPDATE SET | |
| 50 | balance_micros = accounts.balance_micros + excluded.balance_micros, | |
| 51 | customer_id = COALESCE(excluded.customer_id, accounts.customer_id), | |
| 52 | created_at = MIN(accounts.created_at, excluded.created_at)", | |
| 53 | "DELETE FROM accounts WHERE workspace = ?2", | |
| 54 | // Replaced on each report with the month's whole figure: the newer | |
| 55 | // report wins. | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 56 | "INSERT INTO pending_usage (workspace, source, month, charge_micros, cost_micros, charged_at, updated_at) |
| 57 | SELECT ?1, source, month, charge_micros, cost_micros, charged_at, updated_at FROM pending_usage WHERE workspace = ?2 | |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 58 | ON CONFLICT (workspace, source, month) DO UPDATE SET |
| 59 | charge_micros = CASE WHEN excluded.updated_at > pending_usage.updated_at | |
| 60 | THEN excluded.charge_micros ELSE pending_usage.charge_micros END, | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 61 | cost_micros = CASE WHEN excluded.updated_at > pending_usage.updated_at |
| 62 | THEN excluded.cost_micros ELSE pending_usage.cost_micros END, | |
| 63 | charged_at = COALESCE(pending_usage.charged_at, excluded.charged_at), | |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 64 | updated_at = MAX(pending_usage.updated_at, excluded.updated_at)", |
| 65 | "DELETE FROM pending_usage WHERE workspace = ?2", | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 66 | // Monthly allowances drawn by the workspace (its Team credit, its |
| 67 | // build time) add up; a repository's share of the open-source pool | |
| 68 | // follows the repository's new name. | |
| 69 | "INSERT INTO allowance_use (kind, scope, month, used) | |
| 70 | SELECT kind, ?1, month, used FROM allowance_use WHERE scope = ?2 | |
| 71 | ON CONFLICT (kind, scope, month) DO UPDATE SET used = allowance_use.used + excluded.used", | |
| 72 | "DELETE FROM allowance_use WHERE scope = ?2", | |
| 73 | "UPDATE OR IGNORE allowance_use SET scope = ?1 || substr(scope, length(?2) + 1) | |
| 74 | WHERE kind = 'oss_repo' AND substr(scope, 1, length(?2) + 1) = ?2 || '/'", | |
| 75 | // One trial grant per workspace: the current slug's stays if it has one. | |
| 76 | "UPDATE OR IGNORE trial_grants SET workspace = ?1 WHERE workspace = ?2", | |
| 77 | "DELETE FROM trial_grants WHERE workspace = ?2", | |
| 78 | "UPDATE OR IGNORE storage_days SET workspace = ?1 WHERE workspace = ?2", | |
| 79 | "DELETE FROM storage_days WHERE workspace = ?2", | |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 80 | // Limits, field by field: a ceiling or an owner's spend limit set under |
| 81 | // either slug is kept (the current slug's if both), a stop for a | |
| 82 | // declined card stays, and the highest warning this month is kept. | |
| 83 | "INSERT INTO limits (workspace, ceiling_micros, spend_limit_micros, spend_limit_full, autopay_failed_at, | |
| 84 | autopay_error, warned_month, warned_level, declined_told_at, updated_at) | |
| 85 | SELECT ?1, ceiling_micros, spend_limit_micros, spend_limit_full, autopay_failed_at, | |
| 86 | autopay_error, warned_month, warned_level, declined_told_at, updated_at | |
| 87 | FROM limits WHERE workspace = ?2 | |
| 88 | ON CONFLICT (workspace) DO UPDATE SET | |
| 89 | ceiling_micros = COALESCE(limits.ceiling_micros, excluded.ceiling_micros), | |
| 90 | spend_limit_micros = CASE WHEN limits.spend_limit_micros IS NOT NULL OR limits.spend_limit_full = 1 | |
| 91 | THEN limits.spend_limit_micros ELSE excluded.spend_limit_micros END, | |
| 92 | spend_limit_full = CASE WHEN limits.spend_limit_micros IS NOT NULL OR limits.spend_limit_full = 1 | |
| 93 | THEN limits.spend_limit_full ELSE excluded.spend_limit_full END, | |
| 94 | autopay_error = CASE WHEN limits.autopay_failed_at IS NOT NULL | |
| 95 | THEN limits.autopay_error ELSE excluded.autopay_error END, | |
| 96 | autopay_failed_at = COALESCE(limits.autopay_failed_at, excluded.autopay_failed_at), | |
| 97 | warned_level = CASE WHEN excluded.warned_month > COALESCE(limits.warned_month, '') THEN excluded.warned_level | |
| 98 | WHEN excluded.warned_month = limits.warned_month THEN MAX(limits.warned_level, excluded.warned_level) | |
| 99 | ELSE limits.warned_level END, | |
| 100 | warned_month = COALESCE(MAX(limits.warned_month, excluded.warned_month), limits.warned_month, excluded.warned_month), | |
| 101 | declined_told_at = COALESCE(MAX(limits.declined_told_at, excluded.declined_told_at), limits.declined_told_at, excluded.declined_told_at), | |
| 102 | updated_at = MAX(limits.updated_at, excluded.updated_at)", | |
| 103 | "DELETE FROM limits WHERE workspace = ?2", | |
| 104 | // Plans: one per feature. A live plan under the stale slug replaces a | |
| 105 | // canceled one under the current slug; otherwise the current one stays. | |
| 106 | "DELETE FROM subscriptions WHERE workspace = ?1 AND status = 'canceled' | |
| 107 | AND feature IN (SELECT feature FROM subscriptions WHERE workspace = ?2 AND status <> 'canceled')", | |
| 108 | "UPDATE OR IGNORE subscriptions SET workspace = ?1 WHERE workspace = ?2", | |
| 109 | "DELETE FROM subscriptions WHERE workspace = ?2", | |
| 110 | // Records of one per workspace (per month): the current slug's stays | |
| 111 | // if it has one. | |
| 112 | "UPDATE OR IGNORE month_closes SET workspace = ?1 WHERE workspace = ?2", | |
| 113 | "DELETE FROM month_closes WHERE workspace = ?2", | |
| 114 | "UPDATE OR IGNORE account_members SET workspace = ?1 WHERE workspace = ?2", | |
| 115 | "DELETE FROM account_members WHERE workspace = ?2", | |
| 116 | "UPDATE OR IGNORE sales_records SET workspace = ?1 WHERE workspace = ?2", | |
| 117 | "DELETE FROM sales_records WHERE workspace = ?2", | |
| 118 | // An enterprise invoice's line for the workspace: amounts add. | |
| 119 | "INSERT INTO enterprise_invoice_lines (invoice_id, workspace, amount_micros) | |
| 120 | SELECT invoice_id, ?1, amount_micros FROM enterprise_invoice_lines WHERE workspace = ?2 | |
| 121 | ON CONFLICT (invoice_id, workspace) DO UPDATE SET | |
| 122 | amount_micros = enterprise_invoice_lines.amount_micros + excluded.amount_micros", | |
| 123 | "DELETE FROM enterprise_invoice_lines WHERE workspace = ?2", | |
| 124 | // The workspace's own billing account, `ws_<slug>`. Terms set by staff | |
| 125 | // are kept: a standard row under the current slug gives way to the | |
| 126 | // stale one; two with terms keep the current one. | |
| 127 | "DELETE FROM billing_accounts WHERE id = ?3 AND terms_kind = 'standard' | |
| 128 | AND EXISTS (SELECT 1 FROM billing_accounts WHERE id = ?4)", | |
| 129 | "UPDATE OR IGNORE billing_accounts SET id = ?3, name = CASE WHEN name = ?2 THEN ?1 ELSE name END WHERE id = ?4", | |
| 130 | "DELETE FROM billing_accounts WHERE id = ?4", | |
| 131 | "UPDATE admin_actions SET account = ?3 WHERE account = ?4", | |
| 132 | "UPDATE account_members SET account_id = ?3 WHERE account_id = ?4", | |
| 133 | "UPDATE enterprise_invoices SET account_id = ?3 WHERE account_id = ?4", | |
| 134 | ]; | |
| 135 | ||
| 136 | /// The highest `?N` a statement names: how many values it is bound with. | |
| 137 | pub(crate) fn parameters(sql: &str) -> usize { | |
| 138 | let bytes = sql.as_bytes(); | |
| 139 | let mut highest = 0; | |
| 140 | for (i, byte) in bytes.iter().enumerate() { | |
| 141 | if *byte == b'?' { | |
| 142 | let digits: String = bytes[i + 1..].iter().take_while(|b| b.is_ascii_digit()).map(|b| *b as char).collect(); | |
| 143 | highest = highest.max(digits.parse().unwrap_or(0)); | |
| 144 | } | |
| 145 | } | |
| 146 | highest | |
| 147 | } | |
| 148 | ||
| 149 | /// The values `STATEMENTS` are bound with, in parameter order. | |
| 150 | pub(crate) fn values(stale: &str, current: &str) -> [String; 4] { | |
| 151 | [current.to_owned(), stale.to_owned(), own_account(current), own_account(stale)] | |
| 152 | } | |
| 153 | ||
| 154 | impl Billing { | |
| 155 | /// Handles one event from the bus; every type but a rename is ignored. | |
| 156 | pub(crate) async fn on_event(&self, identity: Option<&Fetcher>, event: &Event) -> Result<()> { | |
| 157 | if event.kind != RENAMED { | |
| 158 | return Ok(()); | |
| 159 | } | |
| 160 | let renamed: WorkspaceRenamed = serde_json::from_value(event.data.clone())?; | |
| 161 | let current = match identity { | |
| 162 | Some(identity) => { | |
| 163 | let names: std::collections::HashMap<String, String> = g1t_kit::call( | |
| 164 | identity, | |
| 165 | "usernames", | |
| 166 | &UsernamesArgs { ids: vec![renamed.workspace_id.clone()] }, | |
| 167 | ) | |
| 168 | .await?; | |
| 169 | names.get(&renamed.workspace_id).cloned().unwrap_or_else(|| renamed.to.clone()) | |
| 170 | } | |
| 171 | None => renamed.to.clone(), | |
| 172 | }; | |
| 173 | self.rename_workspace(&renamed, ¤t.to_lowercase()).await | |
| 174 | } | |
| 175 | ||
| 176 | /// Moves every row of the rename's stale slugs to `current`. | |
| 177 | pub(crate) async fn rename_workspace(&self, renamed: &WorkspaceRenamed, current: &str) -> Result<()> { | |
| 178 | for stale in renamed.stale_slugs(current) { | |
| 179 | let stale = stale.to_lowercase(); | |
| 180 | if stale.is_empty() || stale == current { | |
| 181 | continue; | |
| 182 | } | |
| 183 | let values = values(&stale, current); | |
| 184 | let mut batch = Vec::with_capacity(STATEMENTS.len()); | |
| 185 | for sql in STATEMENTS { | |
| 186 | let binds: Vec<JsValue> = values[..parameters(sql)].iter().map(|v| JsValue::from(v.as_str())).collect(); | |
| 187 | batch.push(self.db.prepare(*sql).bind(&binds)?); | |
| 188 | } | |
| 189 | self.db.batch(batch).await?; | |
| 190 | self.rename_customer(&stale, current).await; | |
| 191 | } | |
| 192 | Ok(()) | |
| 193 | } | |
| 194 | ||
| 195 | /// The Stripe customer carries the slug as its name and metadata; both | |
| 196 | /// follow the rename. A name someone changed at Stripe is left alone. | |
| 197 | /// Best effort: a failure here moves no money. | |
| 198 | async fn rename_customer(&self, stale: &str, current: &str) { | |
| 199 | let Some(stripe) = &self.stripe else { return }; | |
| 200 | let Ok(Some(customer)) = self.row(current).await.map(|row| row.and_then(|row| row.customer_id)) else { | |
| 201 | return; | |
| 202 | }; | |
| 203 | #[derive(Deserialize)] | |
| 204 | struct Customer { | |
| 205 | name: Option<String>, | |
| 206 | metadata: Option<std::collections::HashMap<String, String>>, | |
| 207 | } | |
| 208 | let path = format!("/customers/{customer}"); | |
| 209 | let found: Customer = match stripe.get(&path).await { | |
| 210 | Ok(found) => found, | |
| 211 | Err(error) => { | |
| 212 | worker::console_error!("renaming {stale} at Stripe: could not read {customer}: {error}"); | |
| 213 | return; | |
| 214 | } | |
| 215 | }; | |
| 216 | let mut fields = vec![]; | |
| 217 | if found.name.as_deref() == Some(stale) { | |
| 218 | fields.push(("name", current.to_owned())); | |
| 219 | } | |
| 220 | let tagged = found.metadata.as_ref().and_then(|m| m.get("workspace")).map(String::as_str); | |
| 221 | if tagged != Some(current) { | |
| 222 | fields.push(("metadata[workspace]", current.to_owned())); | |
| 223 | } | |
| 224 | if fields.is_empty() { | |
| 225 | return; | |
| 226 | } | |
| 227 | if let Err(error) = stripe.post::<Value>(&path, &fields).await { | |
| 228 | worker::console_error!("renaming {stale} at Stripe: could not update {customer}: {error}"); | |
| 229 | } | |
| 230 | } | |
| 231 | } | |
| 232 | ||
| 233 | #[cfg(test)] | |
| 234 | mod tests { | |
| 235 | use super::*; | |
| 236 | ||
| 237 | #[test] | |
| 238 | fn every_statement_is_bound_with_what_it_names() { | |
| 239 | for sql in STATEMENTS { | |
| 240 | let n = parameters(sql); | |
| 241 | assert!((1..=4).contains(&n), "{sql}"); | |
| 242 | // Numbered only: a bare `?` would take the wrong value. | |
| 243 | assert!(!sql.contains("? ") && !sql.ends_with('?'), "{sql}"); | |
| 244 | } | |
| 245 | assert_eq!(parameters("UPDATE t SET a = ?1 WHERE b = ?2"), 2); | |
| 246 | assert_eq!(parameters("UPDATE t SET a = ?3 WHERE b = ?4"), 4); | |
| 247 | } | |
| 248 | ||
| 249 | #[test] | |
| 250 | fn the_values_name_both_slugs_and_their_accounts() { | |
| 251 | assert_eq!(values("acme", "acme-co"), ["acme-co".to_owned(), "acme".into(), "ws_acme-co".into(), "ws_acme".into()]); | |
| 252 | } | |
| 253 | ||
| 254 | #[test] | |
| 255 | fn every_table_keyed_by_a_slug_is_moved() { | |
| 256 | let all = STATEMENTS.join("\n"); | |
| 257 | for table in [ | |
| Prices are what g1t pays plus 20%, from the first second | 258 | "ledger", "runs", "checkouts", "workspace_invoices", "sales_notes", "accounts", |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 259 | "pending_usage", "limits", "subscriptions", "month_closes", "account_members", "sales_records", |
| 260 | "enterprise_invoice_lines", "billing_accounts", "admin_actions", "enterprise_invoices", | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 261 | "allowance_use", "trial_grants", "storage_days", |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 262 | ] { |
| 263 | assert!(all.contains(&format!("FROM {table} WHERE workspace = ?2")) | |
| 264 | || all.contains(&format!("UPDATE {table} SET")) | |
| 265 | || all.contains(&format!("UPDATE OR IGNORE {table} SET")), "{table}"); | |
| 266 | } | |
| 267 | } | |
| 268 | } |