| 1 | //! A test workspace's billing, wiped: `admin_reset_billing`. |
| 2 | //! |
| 3 | //! While billing runs on Stripe's test key, staff can return a workspace |
| 4 | //! used for testing to how a new customer starts: no ledger, balance, |
| 5 | //! plan, limits, trial grant, invoices, holds, signals or cost rows. Its |
| 6 | //! workspace, members and repositories are not billing's and stay. Never |
| 7 | //! with a live Stripe key, never for a comped workspace, and never for one |
| 8 | //! an enterprise pays for. The reset itself is kept in the audit log, and |
| 9 | //! g1t's own counts of the workspace's git operations stay: they are what |
| 10 | //! Cloudflare's bill is compared with, not what the workspace owes. |
| 11 | |
| 12 | use g1t_contracts::billing::{AdminResetBillingArgs, BillingReset}; |
| 13 | use g1t_contracts::{FailureCode, Outcome}; |
| 14 | use serde::Deserialize; |
| 15 | use worker::Result; |
| 16 | use worker::wasm_bindgen::JsValue; |
| 17 | |
| 18 | use crate::Billing; |
| 19 | use crate::accounts::own_account; |
| 20 | |
| 21 | /// The statements, in order. Parameters: `?1` the workspace, `?2` its own |
| 22 | /// billing account (`ws_<slug>`). |
| 23 | pub(crate) const STATEMENTS: &[&str] = &[ |
| 24 | "DELETE FROM workspace_invoice_lines WHERE invoice_id IN (SELECT invoice_id FROM workspace_invoices WHERE workspace = ?1)", |
| 25 | "DELETE FROM workspace_invoices WHERE workspace = ?1", |
| 26 | "DELETE FROM ledger WHERE workspace = ?1", |
| 27 | "DELETE FROM runs WHERE workspace = ?1", |
| 28 | "DELETE FROM reservations WHERE workspace = ?1", |
| 29 | "DELETE FROM checkouts WHERE workspace = ?1", |
| 30 | "DELETE FROM accounts WHERE workspace = ?1", |
| 31 | "DELETE FROM plan_payments WHERE workspace = ?1", |
| 32 | "DELETE FROM subscriptions WHERE workspace = ?1", |
| 33 | "DELETE FROM limits WHERE workspace = ?1", |
| 34 | "DELETE FROM limit_requests WHERE workspace = ?1", |
| 35 | "DELETE FROM trial_grants WHERE workspace = ?1", |
| 36 | "DELETE FROM card_checks WHERE workspace = ?1", |
| 37 | "DELETE FROM alerts_sent WHERE workspace = ?1", |
| 38 | "DELETE FROM price_notices WHERE workspace = ?1", |
| 39 | "DELETE FROM pending_usage WHERE workspace = ?1", |
| 40 | "DELETE FROM pending_days WHERE workspace = ?1", |
| 41 | "DELETE FROM month_closes WHERE workspace = ?1", |
| 42 | "DELETE FROM storage_days WHERE workspace = ?1", |
| 43 | "DELETE FROM package_storage_days WHERE workspace = ?1", |
| 44 | "DELETE FROM token_usage WHERE workspace = ?1", |
| 45 | "DELETE FROM spikes WHERE workspace = ?1", |
| 46 | "DELETE FROM closed_workspaces WHERE workspace = ?1", |
| 47 | "DELETE FROM sales_records WHERE workspace = ?1", |
| 48 | "DELETE FROM sales_notes WHERE workspace = ?1", |
| 49 | "DELETE FROM workspace_costs WHERE workspace = ?1", |
| 50 | "DELETE FROM margin_alerts WHERE kind = 'workspace' AND subject = ?1", |
| 51 | // Allowances drawn by the workspace, and its repositories' shares of |
| 52 | // the open-source pool (`<slug>/<name>`, compared exactly). |
| 53 | "DELETE FROM allowance_use WHERE scope = ?1 OR (kind = 'oss_repo' AND substr(scope, 1, length(?1) + 1) = ?1 || '/')", |
| 54 | "DELETE FROM budget_alerts WHERE account = ?2", |
| 55 | "DELETE FROM billing_accounts WHERE id = ?2", |
| 56 | ]; |
| 57 | |
| 58 | impl Billing { |
| 59 | pub(crate) async fn admin_reset_billing(&self, env: &worker::Env, a: AdminResetBillingArgs) -> Result<Outcome<BillingReset>> { |
| 60 | let workspace = a.workspace.trim().to_lowercase(); |
| 61 | if workspace.is_empty() || a.by.trim().is_empty() { |
| 62 | return Ok(Outcome::fail(FailureCode::Invalid, "A reset needs a workspace and who did it.")); |
| 63 | } |
| 64 | if a.note.trim().len() < 5 { |
| 65 | return Ok(Outcome::fail(FailureCode::Invalid, "Say why it is reset, for whoever looks next.")); |
| 66 | } |
| 67 | if a.confirm.trim() != workspace { |
| 68 | return Ok(Outcome::fail(FailureCode::Invalid, format!("Type the workspace's slug, {workspace}, exactly, to reset it."))); |
| 69 | } |
| 70 | if self.stripe.as_ref().is_some_and(|s| s.live()) { |
| 71 | return Ok(Outcome::fail(FailureCode::Forbidden, "Billing takes real cards: a workspace's billing is never wiped.")); |
| 72 | } |
| 73 | #[derive(Deserialize)] |
| 74 | struct Found { |
| 75 | comped: i64, |
| 76 | enterprise: i64, |
| 77 | } |
| 78 | let found = self |
| 79 | .db |
| 80 | .prepare(format!( |
| 81 | "SELECT CASE WHEN ?1 IN ({}) THEN 1 ELSE 0 END AS comped, |
| 82 | (SELECT COUNT(*) FROM account_members WHERE workspace = ?1) AS enterprise", |
| 83 | crate::sales::INTERNAL_SQL |
| 84 | )) |
| 85 | .bind(&[workspace.as_str().into()])? |
| 86 | .first::<Found>(None) |
| 87 | .await?; |
| 88 | if let Some(found) = found { |
| 89 | if found.comped > 0 { |
| 90 | return Ok(Outcome::fail(FailureCode::Forbidden, format!("{workspace} is comped (g1t's own): its spend is a budget, kept."))); |
| 91 | } |
| 92 | if found.enterprise > 0 { |
| 93 | return Ok(Outcome::fail(FailureCode::Forbidden, format!("An enterprise pays for {workspace}: move it off first."))); |
| 94 | } |
| 95 | } |
| 96 | let account = own_account(&workspace); |
| 97 | let mut batch = Vec::with_capacity(STATEMENTS.len()); |
| 98 | for sql in STATEMENTS { |
| 99 | let values: Vec<JsValue> = |
| 100 | [workspace.as_str(), account.as_str()][..crate::rename::parameters(sql)].iter().map(|v| (*v).into()).collect(); |
| 101 | batch.push(self.db.prepare(*sql).bind(&values)?); |
| 102 | } |
| 103 | let mut rows = 0usize; |
| 104 | for result in self.db.batch(batch).await? { |
| 105 | rows += result.meta()?.and_then(|m| m.changes).unwrap_or(0); |
| 106 | } |
| 107 | self.audit(&account, "reset", &format!("billing of {workspace} reset ({rows} rows): {}", a.note.trim()), &a.by).await?; |
| 108 | // The margin figures still hold the workspace's past usage: redo |
| 109 | // them now (the day's analysis: the bill, 31 days, the alerts), so |
| 110 | // the pages show the reset at once. |
| 111 | let refreshed = match self.costs_daily(env, &crate::keeper::Keeper::from_env(env)).await { |
| 112 | Ok(run) => run.problems.is_empty(), |
| 113 | Err(error) => { |
| 114 | worker::console_error!("costs after a reset of {workspace}: {error}"); |
| 115 | false |
| 116 | } |
| 117 | }; |
| 118 | Ok(Outcome::Ok(BillingReset { workspace, rows: rows as u32, refreshed })) |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | #[cfg(test)] |
| 123 | mod tests { |
| 124 | use super::*; |
| 125 | |
| 126 | #[test] |
| 127 | fn every_table_with_a_workspace_is_wiped_or_kept_on_purpose() { |
| 128 | let all = STATEMENTS.join("\n"); |
| 129 | // What is kept: the audit log, and g1t's own counts compared with |
| 130 | // Cloudflare's bill. |
| 131 | let kept = ["admin_actions", "own_counts"]; |
| 132 | for table in [ |
| 133 | "ledger", "runs", "checkouts", "workspace_invoices", "workspace_invoice_lines", "sales_notes", "accounts", |
| 134 | "pending_usage", "pending_days", "limits", "subscriptions", "month_closes", "sales_records", |
| 135 | "billing_accounts", "allowance_use", "trial_grants", "storage_days", "package_storage_days", |
| 136 | "token_usage", "reservations", "spikes", "limit_requests", "plan_payments", |
| 137 | "card_checks", "alerts_sent", "price_notices", "closed_workspaces", "workspace_costs", |
| 138 | "margin_alerts", "budget_alerts", |
| 139 | ] { |
| 140 | assert!(!kept.contains(&table)); |
| 141 | assert!(all.contains(&format!("DELETE FROM {table} WHERE")), "{table}"); |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | /// The tables the migrations leave: every one made, less those dropped. |
| 146 | fn live_tables() -> std::collections::BTreeSet<String> { |
| 147 | let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("migrations"); |
| 148 | let mut files: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().path()).collect(); |
| 149 | files.sort(); |
| 150 | let mut live = std::collections::BTreeSet::new(); |
| 151 | for file in files { |
| 152 | let sql = std::fs::read_to_string(file).unwrap(); |
| 153 | for line in sql.lines().map(str::trim) { |
| 154 | let words: Vec<&str> = line.split(|c: char| c.is_whitespace() || c == '(' || c == ';').filter(|w| !w.is_empty()).collect(); |
| 155 | let name = |at: usize| words.get(at).map(|w| w.to_string()); |
| 156 | match words.as_slice() { |
| 157 | ["CREATE", "TABLE", "IF", "NOT", "EXISTS", ..] => live.extend(name(5)), |
| 158 | ["CREATE", "TABLE", ..] => live.extend(name(2)), |
| 159 | ["DROP", "TABLE", "IF", "EXISTS", ..] => { |
| 160 | name(4).map(|n| live.remove(&n)); |
| 161 | } |
| 162 | ["DROP", "TABLE", ..] => { |
| 163 | name(2).map(|n| live.remove(&n)); |
| 164 | } |
| 165 | _ => {} |
| 166 | } |
| 167 | } |
| 168 | } |
| 169 | live |
| 170 | } |
| 171 | |
| 172 | #[test] |
| 173 | fn every_table_wiped_is_one_the_migrations_leave() { |
| 174 | let live = live_tables(); |
| 175 | assert!(live.contains("ledger") && !live.contains("sandbox_months"), "{live:?}"); |
| 176 | for sql in STATEMENTS { |
| 177 | let table = sql.split_whitespace().nth(2).unwrap(); |
| 178 | assert!(live.contains(table), "{table} is not a table after the migrations"); |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | #[test] |
| 183 | fn statements_name_at_most_the_workspace_and_its_account() { |
| 184 | assert!(STATEMENTS.iter().all(|sql| crate::rename::parameters(sql) <= 2)); |
| 185 | assert_eq!(crate::rename::parameters(STATEMENTS.last().unwrap()), 2); |
| 186 | } |
| 187 | } |