Skip to content

g1t/services/billing/src/rename.rs

325 lines18,929 bytesCodeBlame

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 domains1//! 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
14use g1t_contracts::events::{Event, WorkspaceRenamed};
15use g1t_contracts::identity::UsernamesArgs;
16use serde::Deserialize;
17use serde_json::Value;
18use worker::wasm_bindgen::JsValue;
19use worker::{Fetcher, Result};
20
21use crate::Billing;
22use crate::accounts::own_account;
23
24/// The event this module handles.
25pub(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.
33pub(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",
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging37 "UPDATE credit_grants SET workspace = ?1 WHERE workspace = ?2",
Agents and memory, checks and conflicts, profiles, slug renames, custom domains38 "UPDATE checkouts SET workspace = ?1 WHERE workspace = ?2",
Usage, Billing settings and prepaid AI credit; fixes from the UX audit39 "UPDATE ai_reloads SET workspace = ?1 WHERE workspace = ?2",
40 // Auto-reload's settings: the stale slug's, when the current has none.
41 "INSERT OR IGNORE INTO ai_reload (workspace, enabled, threshold_micros, target_micros, monthly_max_micros, updated_by, updated_at, failed_at, error)
42 SELECT ?1, enabled, threshold_micros, target_micros, monthly_max_micros, updated_by, updated_at, failed_at, error FROM ai_reload WHERE workspace = ?2",
43 "DELETE FROM ai_reload WHERE workspace = ?2",
Agents and memory, checks and conflicts, profiles, slug renames, custom domains44 "UPDATE workspace_invoices SET workspace = ?1 WHERE workspace = ?2",
45 "UPDATE sales_notes SET workspace = ?1 WHERE workspace = ?2",
46 // Repositories are named `<slug>/<name>`. Compared exactly rather than
47 // with LIKE, so nothing in a slug is read as a wildcard.
48 "UPDATE ledger SET repo = ?1 || substr(repo, length(?2) + 1) WHERE substr(repo, 1, length(?2) + 1) = ?2 || '/'",
49 "UPDATE runs SET repo = ?1 || substr(repo, length(?2) + 1) WHERE substr(repo, 1, length(?2) + 1) = ?2 || '/'",
50 // The balance is the sum of the ledger, and both ledgers are now the
51 // current slug's: the balances add. The older customer (with the card
52 // and the payment history) is kept when both have one.
53 "INSERT INTO accounts (workspace, balance_micros, customer_id, created_at)
54 SELECT ?1, balance_micros, customer_id, created_at FROM accounts WHERE workspace = ?2
55 ON CONFLICT (workspace) DO UPDATE SET
56 balance_micros = accounts.balance_micros + excluded.balance_micros,
57 customer_id = COALESCE(excluded.customer_id, accounts.customer_id),
58 created_at = MIN(accounts.created_at, excluded.created_at)",
59 "DELETE FROM accounts WHERE workspace = ?2",
60 // Replaced on each report with the month's whole figure: the newer
61 // report wins.
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas62 "INSERT INTO pending_usage (workspace, source, month, charge_micros, cost_micros, charged_at, updated_at, detail)
63 SELECT ?1, source, month, charge_micros, cost_micros, charged_at, updated_at, detail FROM pending_usage WHERE workspace = ?2
Agents and memory, checks and conflicts, profiles, slug renames, custom domains64 ON CONFLICT (workspace, source, month) DO UPDATE SET
65 charge_micros = CASE WHEN excluded.updated_at > pending_usage.updated_at
66 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 put67 cost_micros = CASE WHEN excluded.updated_at > pending_usage.updated_at
68 THEN excluded.cost_micros ELSE pending_usage.cost_micros END,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas69 detail = CASE WHEN excluded.updated_at > pending_usage.updated_at
70 THEN excluded.detail ELSE pending_usage.detail END,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put71 charged_at = COALESCE(pending_usage.charged_at, excluded.charged_at),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains72 updated_at = MAX(pending_usage.updated_at, excluded.updated_at)",
73 "DELETE FROM pending_usage WHERE workspace = ?2",
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look74 // Monthly allowances drawn by the workspace (its plan's included usage,
75 // its build time) add up; a repository's share of the open-source pool
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put76 // follows the repository's new name.
77 "INSERT INTO allowance_use (kind, scope, month, used)
78 SELECT kind, ?1, month, used FROM allowance_use WHERE scope = ?2
79 ON CONFLICT (kind, scope, month) DO UPDATE SET used = allowance_use.used + excluded.used",
80 "DELETE FROM allowance_use WHERE scope = ?2",
81 "UPDATE OR IGNORE allowance_use SET scope = ?1 || substr(scope, length(?2) + 1)
82 WHERE kind = 'oss_repo' AND substr(scope, 1, length(?2) + 1) = ?2 || '/'",
83 // One trial grant per workspace: the current slug's stays if it has one.
84 "UPDATE OR IGNORE trial_grants SET workspace = ?1 WHERE workspace = ?2",
85 "DELETE FROM trial_grants WHERE workspace = ?2",
86 "UPDATE OR IGNORE storage_days SET workspace = ?1 WHERE workspace = ?2",
87 "DELETE FROM storage_days WHERE workspace = ?2",
Billing: a workspace rename moves its token usage, package storage, month-end snapshots, price notices, cost and count rows, margin alert and budget alerts too; a test keeps the rename and the reset naming the same tables88 "UPDATE OR IGNORE package_storage_days SET workspace = ?1 WHERE workspace = ?2",
89 "DELETE FROM package_storage_days WHERE workspace = ?2",
90 // Month-end snapshots, by day: the current slug's stays if it has one.
91 "UPDATE OR IGNORE pending_days SET workspace = ?1 WHERE workspace = ?2",
92 "DELETE FROM pending_days WHERE workspace = ?2",
93 // Model tokens, by day, person, session and model: the counts add.
94 "INSERT INTO token_usage (day, workspace, person, session, model, tier, input, output, cache_read, cache_write, requests)
95 SELECT day, ?1, person, session, model, tier, input, output, cache_read, cache_write, requests FROM token_usage WHERE workspace = ?2
96 ON CONFLICT (day, workspace, person, session, model) DO UPDATE SET
97 input = token_usage.input + excluded.input,
98 output = token_usage.output + excluded.output,
99 cache_read = token_usage.cache_read + excluded.cache_read,
100 cache_write = token_usage.cache_write + excluded.cache_write,
101 requests = token_usage.requests + excluded.requests",
102 "DELETE FROM token_usage WHERE workspace = ?2",
103 // Price notices sent, one per version: told once is told.
104 "UPDATE OR IGNORE price_notices SET workspace = ?1 WHERE workspace = ?2",
105 "DELETE FROM price_notices WHERE workspace = ?2",
106 "UPDATE OR IGNORE closed_workspaces SET workspace = ?1 WHERE workspace = ?2",
107 "DELETE FROM closed_workspaces WHERE workspace = ?2",
108 // The margin figures and counts are redone each day from the ledger and
109 // the meters; moved so the days between keep adding up.
110 "UPDATE OR IGNORE workspace_costs SET workspace = ?1 WHERE workspace = ?2",
111 "DELETE FROM workspace_costs WHERE workspace = ?2",
112 "UPDATE OR IGNORE own_counts SET workspace = ?1 WHERE workspace = ?2",
113 "DELETE FROM own_counts WHERE workspace = ?2",
114 "UPDATE margin_alerts SET subject = ?1 WHERE kind = 'workspace' AND subject = ?2",
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look115 // Holds, spikes, requests and the plan's payments: many per workspace.
116 "UPDATE reservations SET workspace = ?1 WHERE workspace = ?2",
117 "UPDATE reservations SET repo = ?1 || substr(repo, length(?2) + 1) WHERE substr(repo, 1, length(?2) + 1) = ?2 || '/'",
118 "UPDATE spikes SET workspace = ?1 WHERE workspace = ?2",
119 "UPDATE limit_requests SET workspace = ?1 WHERE workspace = ?2",
120 "UPDATE plan_payments SET workspace = ?1 WHERE workspace = ?2",
Merge Stripe Tax, the card fee on card payments, and one free workspace per person121 "UPDATE tax_and_fees SET workspace = ?1 WHERE workspace = ?2",
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look122 // One card check per workspace; alerts sent, one per level a month.
123 "UPDATE OR IGNORE card_checks SET workspace = ?1 WHERE workspace = ?2",
124 "DELETE FROM card_checks WHERE workspace = ?2",
125 "UPDATE OR IGNORE alerts_sent SET workspace = ?1 WHERE workspace = ?2",
126 "DELETE FROM alerts_sent WHERE workspace = ?2",
Agents and memory, checks and conflicts, profiles, slug renames, custom domains127 // Limits, field by field: a ceiling or an owner's spend limit set under
128 // either slug is kept (the current slug's if both), a stop for a
129 // declined card stays, and the highest warning this month is kept.
130 "INSERT INTO limits (workspace, ceiling_micros, spend_limit_micros, spend_limit_full, autopay_failed_at,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look131 autopay_error, warned_month, warned_level, declined_told_at, updated_at,
132 max_ceiling_micros, granted_ceiling_micros, raised_at)
Agents and memory, checks and conflicts, profiles, slug renames, custom domains133 SELECT ?1, ceiling_micros, spend_limit_micros, spend_limit_full, autopay_failed_at,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look134 autopay_error, warned_month, warned_level, declined_told_at, updated_at,
135 max_ceiling_micros, granted_ceiling_micros, raised_at
Agents and memory, checks and conflicts, profiles, slug renames, custom domains136 FROM limits WHERE workspace = ?2
137 ON CONFLICT (workspace) DO UPDATE SET
138 ceiling_micros = COALESCE(limits.ceiling_micros, excluded.ceiling_micros),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look139 max_ceiling_micros = MAX(COALESCE(limits.max_ceiling_micros, 0), COALESCE(excluded.max_ceiling_micros, 0)),
140 granted_ceiling_micros = MAX(COALESCE(limits.granted_ceiling_micros, 0), COALESCE(excluded.granted_ceiling_micros, 0)),
141 raised_at = COALESCE(limits.raised_at, excluded.raised_at),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains142 spend_limit_micros = CASE WHEN limits.spend_limit_micros IS NOT NULL OR limits.spend_limit_full = 1
143 THEN limits.spend_limit_micros ELSE excluded.spend_limit_micros END,
144 spend_limit_full = CASE WHEN limits.spend_limit_micros IS NOT NULL OR limits.spend_limit_full = 1
145 THEN limits.spend_limit_full ELSE excluded.spend_limit_full END,
146 autopay_error = CASE WHEN limits.autopay_failed_at IS NOT NULL
147 THEN limits.autopay_error ELSE excluded.autopay_error END,
148 autopay_failed_at = COALESCE(limits.autopay_failed_at, excluded.autopay_failed_at),
149 warned_level = CASE WHEN excluded.warned_month > COALESCE(limits.warned_month, '') THEN excluded.warned_level
150 WHEN excluded.warned_month = limits.warned_month THEN MAX(limits.warned_level, excluded.warned_level)
151 ELSE limits.warned_level END,
152 warned_month = COALESCE(MAX(limits.warned_month, excluded.warned_month), limits.warned_month, excluded.warned_month),
153 declined_told_at = COALESCE(MAX(limits.declined_told_at, excluded.declined_told_at), limits.declined_told_at, excluded.declined_told_at),
154 updated_at = MAX(limits.updated_at, excluded.updated_at)",
155 "DELETE FROM limits WHERE workspace = ?2",
156 // Plans: one per feature. A live plan under the stale slug replaces a
157 // canceled one under the current slug; otherwise the current one stays.
158 "DELETE FROM subscriptions WHERE workspace = ?1 AND status = 'canceled'
159 AND feature IN (SELECT feature FROM subscriptions WHERE workspace = ?2 AND status <> 'canceled')",
160 "UPDATE OR IGNORE subscriptions SET workspace = ?1 WHERE workspace = ?2",
161 "DELETE FROM subscriptions WHERE workspace = ?2",
162 // Records of one per workspace (per month): the current slug's stays
163 // if it has one.
164 "UPDATE OR IGNORE month_closes SET workspace = ?1 WHERE workspace = ?2",
165 "DELETE FROM month_closes WHERE workspace = ?2",
166 "UPDATE OR IGNORE account_members SET workspace = ?1 WHERE workspace = ?2",
167 "DELETE FROM account_members WHERE workspace = ?2",
168 "UPDATE OR IGNORE sales_records SET workspace = ?1 WHERE workspace = ?2",
169 "DELETE FROM sales_records WHERE workspace = ?2",
170 // An enterprise invoice's line for the workspace: amounts add.
171 "INSERT INTO enterprise_invoice_lines (invoice_id, workspace, amount_micros)
172 SELECT invoice_id, ?1, amount_micros FROM enterprise_invoice_lines WHERE workspace = ?2
173 ON CONFLICT (invoice_id, workspace) DO UPDATE SET
174 amount_micros = enterprise_invoice_lines.amount_micros + excluded.amount_micros",
175 "DELETE FROM enterprise_invoice_lines WHERE workspace = ?2",
176 // The workspace's own billing account, `ws_<slug>`. Terms set by staff
177 // are kept: a standard row under the current slug gives way to the
178 // stale one; two with terms keep the current one.
179 "DELETE FROM billing_accounts WHERE id = ?3 AND terms_kind = 'standard'
180 AND EXISTS (SELECT 1 FROM billing_accounts WHERE id = ?4)",
181 "UPDATE OR IGNORE billing_accounts SET id = ?3, name = CASE WHEN name = ?2 THEN ?1 ELSE name END WHERE id = ?4",
182 "DELETE FROM billing_accounts WHERE id = ?4",
183 "UPDATE admin_actions SET account = ?3 WHERE account = ?4",
Billing: a workspace rename moves its token usage, package storage, month-end snapshots, price notices, cost and count rows, margin alert and budget alerts too; a test keeps the rename and the reset naming the same tables184 "UPDATE OR IGNORE budget_alerts SET account = ?3 WHERE account = ?4",
185 "DELETE FROM budget_alerts WHERE account = ?4",
Agents and memory, checks and conflicts, profiles, slug renames, custom domains186 "UPDATE account_members SET account_id = ?3 WHERE account_id = ?4",
187 "UPDATE enterprise_invoices SET account_id = ?3 WHERE account_id = ?4",
188];
189
190/// The highest `?N` a statement names: how many values it is bound with.
191pub(crate) fn parameters(sql: &str) -> usize {
192 let bytes = sql.as_bytes();
193 let mut highest = 0;
194 for (i, byte) in bytes.iter().enumerate() {
195 if *byte == b'?' {
196 let digits: String = bytes[i + 1..].iter().take_while(|b| b.is_ascii_digit()).map(|b| *b as char).collect();
197 highest = highest.max(digits.parse().unwrap_or(0));
198 }
199 }
200 highest
201}
202
203/// The values `STATEMENTS` are bound with, in parameter order.
204pub(crate) fn values(stale: &str, current: &str) -> [String; 4] {
205 [current.to_owned(), stale.to_owned(), own_account(current), own_account(stale)]
206}
207
208impl Billing {
209 /// Handles one event from the bus; every type but a rename is ignored.
210 pub(crate) async fn on_event(&self, identity: Option<&Fetcher>, event: &Event) -> Result<()> {
211 if event.kind != RENAMED {
212 return Ok(());
213 }
214 let renamed: WorkspaceRenamed = serde_json::from_value(event.data.clone())?;
215 let current = match identity {
216 Some(identity) => {
217 let names: std::collections::HashMap<String, String> = g1t_kit::call(
218 identity,
219 "usernames",
220 &UsernamesArgs { ids: vec![renamed.workspace_id.clone()] },
221 )
222 .await?;
223 names.get(&renamed.workspace_id).cloned().unwrap_or_else(|| renamed.to.clone())
224 }
225 None => renamed.to.clone(),
226 };
227 self.rename_workspace(&renamed, &current.to_lowercase()).await
228 }
229
230 /// Moves every row of the rename's stale slugs to `current`.
231 pub(crate) async fn rename_workspace(&self, renamed: &WorkspaceRenamed, current: &str) -> Result<()> {
232 for stale in renamed.stale_slugs(current) {
233 let stale = stale.to_lowercase();
234 if stale.is_empty() || stale == current {
235 continue;
236 }
237 let values = values(&stale, current);
238 let mut batch = Vec::with_capacity(STATEMENTS.len());
239 for sql in STATEMENTS {
240 let binds: Vec<JsValue> = values[..parameters(sql)].iter().map(|v| JsValue::from(v.as_str())).collect();
241 batch.push(self.db.prepare(*sql).bind(&binds)?);
242 }
243 self.db.batch(batch).await?;
244 self.rename_customer(&stale, current).await;
245 }
246 Ok(())
247 }
248
249 /// The Stripe customer carries the slug as its name and metadata; both
250 /// follow the rename. A name someone changed at Stripe is left alone.
251 /// Best effort: a failure here moves no money.
252 async fn rename_customer(&self, stale: &str, current: &str) {
253 let Some(stripe) = &self.stripe else { return };
254 let Ok(Some(customer)) = self.row(current).await.map(|row| row.and_then(|row| row.customer_id)) else {
255 return;
256 };
257 #[derive(Deserialize)]
258 struct Customer {
259 name: Option<String>,
260 metadata: Option<std::collections::HashMap<String, String>>,
261 }
262 let path = format!("/customers/{customer}");
263 let found: Customer = match stripe.get(&path).await {
264 Ok(found) => found,
265 Err(error) => {
266 worker::console_error!("renaming {stale} at Stripe: could not read {customer}: {error}");
267 return;
268 }
269 };
270 let mut fields = vec![];
271 if found.name.as_deref() == Some(stale) {
272 fields.push(("name", current.to_owned()));
273 }
274 let tagged = found.metadata.as_ref().and_then(|m| m.get("workspace")).map(String::as_str);
275 if tagged != Some(current) {
276 fields.push(("metadata[workspace]", current.to_owned()));
277 }
278 if fields.is_empty() {
279 return;
280 }
281 if let Err(error) = stripe.post::<Value>(&path, &fields).await {
282 worker::console_error!("renaming {stale} at Stripe: could not update {customer}: {error}");
283 }
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn every_statement_is_bound_with_what_it_names() {
293 for sql in STATEMENTS {
294 let n = parameters(sql);
295 assert!((1..=4).contains(&n), "{sql}");
296 // Numbered only: a bare `?` would take the wrong value.
297 assert!(!sql.contains("? ") && !sql.ends_with('?'), "{sql}");
298 }
299 assert_eq!(parameters("UPDATE t SET a = ?1 WHERE b = ?2"), 2);
300 assert_eq!(parameters("UPDATE t SET a = ?3 WHERE b = ?4"), 4);
301 }
302
303 #[test]
304 fn the_values_name_both_slugs_and_their_accounts() {
305 assert_eq!(values("acme", "acme-co"), ["acme-co".to_owned(), "acme".into(), "ws_acme-co".into(), "ws_acme".into()]);
306 }
307
308 #[test]
309 fn every_table_keyed_by_a_slug_is_moved() {
310 let all = STATEMENTS.join("\n");
311 for table in [
Prices are what g1t pays plus 20%, from the first second312 "ledger", "runs", "checkouts", "workspace_invoices", "sales_notes", "accounts",
Agents and memory, checks and conflicts, profiles, slug renames, custom domains313 "pending_usage", "limits", "subscriptions", "month_closes", "account_members", "sales_records",
314 "enterprise_invoice_lines", "billing_accounts", "admin_actions", "enterprise_invoices",
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging315 "allowance_use", "trial_grants", "credit_grants", "storage_days",
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look316 "reservations", "spikes", "limit_requests", "plan_payments", "card_checks", "alerts_sent",
Billing: a workspace rename moves its token usage, package storage, month-end snapshots, price notices, cost and count rows, margin alert and budget alerts too; a test keeps the rename and the reset naming the same tables317 "package_storage_days", "pending_days", "token_usage", "price_notices", "closed_workspaces",
Merge Stripe Tax, the card fee on card payments, and one free workspace per person318 "workspace_costs", "own_counts", "ai_reload", "ai_reloads", "tax_and_fees",
Agents and memory, checks and conflicts, profiles, slug renames, custom domains319 ] {
320 assert!(all.contains(&format!("FROM {table} WHERE workspace = ?2"))
321 || all.contains(&format!("UPDATE {table} SET"))
322 || all.contains(&format!("UPDATE OR IGNORE {table} SET")), "{table}");
323 }
324 }
325}

This file's history is long; its oldest lines are credited to the oldest commit read.