Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
1,013 linesCodeBlameRaw
1//! Workspaces moved off Stripe's test mode: `admin_detach_test_stripe`.
2//!
3//! Before billing takes a live Stripe key, every id and amount Stripe's test
4//! mode left behind has to stop counting: a plan still "active" until its
5//! period ends, a cached 4242 card, a test customer id (the first live month
6//! close would charge test-era balances to whatever card it then finds),
7//! open invoices, and test payments that count as paid. A testing reset
8//! (`reset.rs`) wipes all of that and the history with it; this keeps the
9//! history and makes it inert:
10//!
11//! - plans canceled; the customer id, cached card and tax-address flag
12//! cleared; card checks and open checkouts removed (the trial grant stays,
13//! so there is no second trial); AI auto-reload off; a declined autopay
14//! cleared; open and failed invoices void;
15//! - every past month from the workspace's first ledger line marked closed
16//! (`skipped`), so the first live close charges none of them;
17//! - AI and purchased credit closed (hosted agents are gone, so it can buy
18//! nothing), test payments reversed on the ledger and plan payments by a
19//! reversing row each, so none of it counts as paid (nor as having had
20//! the plan that month, `storage::PLAN_EVIDENCE`); and what that leaves
21//! owed written off as goodwill credit (a `crd…` grant, never a payment,
22//! and no email to the owners);
23//! - usage metered to be charged at a month's end: every month before this
24//! one marked charged (`never: test mode …`), and this month's figure so
25//! far kept as its baseline (`pending_baselines`), which everything that
26//! values or charges it takes off, since reporters keep replacing the row
27//! with the month's whole total;
28//! - this month's draws on the plan's included usage deleted, so a live
29//! plan taken this month has all of it (the trial grant and g1t's
30//! open-source pool are left: the trial is once ever, and the pool's
31//! draws were g1t's own money spent);
32//! - what lost test-mode disputes left owed cleared, and open ones closed
33//! (`detached`), so no test dispute holds the workspace's work.
34//!
35//! All of it but the goodwill grant is one D1 batch per workspace; the
36//! grant goes through `grant_credit` after it. Running it again changes
37//! nothing: every step reads what is left to do. Never with a live key.
38//! Every workspace at once skips comped and enterprise workspaces and an
39//! exclusion list (`syntaqx` and `flagon-io` by default); one workspace can
40//! be any but an enterprise's.
41
42use std::collections::{BTreeMap, BTreeSet};
43
44use g1t_contracts::billing::{
45 AdminDetachTestStripeArgs, CreditKind, DetachSkipped, TestStripeDetach, WorkspaceDetach,
46};
47use g1t_contracts::time::rfc3339;
48use g1t_contracts::{FailureCode, Outcome, new_id};
49use g1t_kit::now_ms;
50use serde::Deserialize;
51use worker::Result;
52use worker::wasm_bindgen::JsValue;
53
54use crate::Billing;
55use crate::accounts::own_account;
56use crate::features::cents;
57
58/// Left alone by a detach of every workspace unless named.
59pub(crate) const DEFAULT_EXCLUDED: [&str; 2] = ["syntaqx", "flagon-io"];
60/// What staff type to detach every workspace.
61pub(crate) const CONFIRM_ALL: &str = "detach";
62/// Workspaces one call detaches; the rest are `remaining`.
63pub(crate) const PER_CALL: usize = 25;
64/// The goodwill grant's note.
65pub(crate) const WRITE_OFF_NOTE: &str = "Usage while payments were in test mode, written off";
66/// A skipped month's `error`, which is where `month_closes` keeps why.
67pub(crate) const MONTH_NOTE: &str = "test-mode period, written off";
68/// The suffix of a plan payment's reversing row (12 characters).
69#[cfg(test)]
70const PLAN_REVERSAL: &str = "_test_detach";
71
72/// Every workspace holding a billing row, with what test mode left on it.
73/// `?1` one workspace, or NULL for all; `?2` this month (`YYYY-MM`).
74/// `{internal}` is `sales::INTERNAL_SQL`.
75pub(crate) const FOUND: &str = "WITH candidates(workspace) AS (
76 SELECT workspace FROM accounts
77 UNION SELECT workspace FROM subscriptions
78 UNION SELECT workspace FROM card_checks
79 UNION SELECT workspace FROM checkouts WHERE status = 'open'
80 UNION SELECT workspace FROM workspace_invoices
81 UNION SELECT workspace FROM credit_grants
82 UNION SELECT workspace FROM plan_payments
83 UNION SELECT workspace FROM ai_reload
84 UNION SELECT workspace FROM limits WHERE autopay_failed_at IS NOT NULL OR autopay_error IS NOT NULL OR dispute_owed_micros <> 0
85 UNION SELECT workspace FROM pending_usage WHERE charged_at IS NULL
86 UNION SELECT scope FROM allowance_use WHERE kind = 'plan_credit' AND month = ?2
87 UNION SELECT workspace FROM disputes WHERE status = 'open'
88 )
89 SELECT c.workspace AS workspace,
90 CASE WHEN c.workspace IN ({internal}) THEN 1 ELSE 0 END AS comped,
91 (SELECT COUNT(*) FROM account_members m WHERE m.workspace = c.workspace) AS enterprise,
92 CASE WHEN a.customer_id IS NOT NULL THEN 1 ELSE 0 END AS customer,
93 a.card_brand AS card_brand,
94 a.card_last4 AS card_last4,
95 CASE WHEN a.card_brand IS NOT NULL OR a.card_last4 IS NOT NULL OR a.card_exp_month IS NOT NULL
96 OR a.card_exp_year IS NOT NULL OR a.card_synced_at IS NOT NULL OR a.tax_address_needed_at IS NOT NULL
97 THEN 1 ELSE 0 END AS card_cached,
98 COALESCE(a.balance_micros, 0) AS balance,
99 (SELECT COUNT(*) FROM subscriptions s WHERE s.workspace = c.workspace AND s.status <> 'canceled') AS subscriptions,
100 (SELECT COUNT(*) FROM card_checks k WHERE k.workspace = c.workspace) AS card_checks,
101 (SELECT COUNT(*) FROM checkouts o WHERE o.workspace = c.workspace AND o.status = 'open') AS checkouts,
102 (SELECT COUNT(*) FROM ai_reload r WHERE r.workspace = c.workspace AND r.enabled <> 0) AS ai_reload,
103 (SELECT COUNT(*) FROM limits l WHERE l.workspace = c.workspace
104 AND (l.autopay_failed_at IS NOT NULL OR l.autopay_error IS NOT NULL)) AS autopay_failed,
105 (SELECT COUNT(*) FROM workspace_invoices i WHERE i.workspace = c.workspace AND i.status IN ('open', 'failed')) AS invoices,
106 (SELECT COUNT(*) FROM credit_grants g WHERE g.workspace = c.workspace AND g.closed_at IS NULL
107 AND (g.kind = 'purchased' OR g.scope = 'models' OR g.source = 'purchase')) AS ai_grants,
108 (SELECT COALESCE(SUM(e.amount_micros), 0) FROM ledger e
109 WHERE e.workspace = c.workspace AND e.kind = 'top_up' AND e.reference NOT LIKE 'crd%') AS paid,
110 (SELECT COALESCE(SUM(p.amount_micros), 0) FROM plan_payments p
111 WHERE p.workspace = c.workspace AND p.amount_micros > 0 AND substr(p.invoice_id, -12) <> '_test_detach'
112 AND NOT EXISTS (SELECT 1 FROM plan_payments q WHERE q.invoice_id = p.invoice_id || '_test_detach')) AS plan_unreversed,
113 (SELECT MIN(e.created_at) FROM ledger e WHERE e.workspace = c.workspace) AS first_entry,
114 (SELECT COUNT(*) FROM pending_usage u WHERE u.workspace = c.workspace AND u.month < ?2 AND u.charged_at IS NULL) AS pending_past,
115 (SELECT COALESCE(SUM(u.charge_micros), 0) FROM pending_usage u
116 WHERE u.workspace = c.workspace AND u.month < ?2 AND u.charged_at IS NULL) AS pending_past_micros,
117 (SELECT COUNT(*) FROM pending_usage u LEFT JOIN pending_baselines b
118 ON b.workspace = u.workspace AND b.source = u.source AND b.month = u.month
119 WHERE u.workspace = c.workspace AND u.month = ?2 AND u.charged_at IS NULL
120 AND (u.cost_micros > COALESCE(b.cost_micros, 0) OR u.charge_micros > COALESCE(b.charge_micros, 0))) AS pending_now,
121 (SELECT COALESCE(SUM(MAX(0, u.charge_micros - COALESCE(b.charge_micros, 0))), 0) FROM pending_usage u LEFT JOIN pending_baselines b
122 ON b.workspace = u.workspace AND b.source = u.source AND b.month = u.month
123 WHERE u.workspace = c.workspace AND u.month = ?2 AND u.charged_at IS NULL) AS pending_now_micros,
124 (SELECT COALESCE(SUM(w.used), 0) FROM allowance_use w WHERE w.kind = 'plan_credit' AND w.scope = c.workspace AND w.month = ?2) AS plan_credit,
125 (SELECT COALESCE(SUM(l.dispute_owed_micros), 0) FROM limits l WHERE l.workspace = c.workspace) AS dispute_owed,
126 (SELECT COUNT(*) FROM disputes d WHERE d.workspace = c.workspace AND d.status = 'open') AS open_disputes
127 FROM candidates c LEFT JOIN accounts a ON a.workspace = c.workspace
128 WHERE ?1 IS NULL OR c.workspace = ?1
129 ORDER BY c.workspace";
130
131/// The months already closed. `?1` one workspace, or NULL for all.
132pub(crate) const CLOSED: &str = "SELECT workspace, month FROM month_closes WHERE ?1 IS NULL OR workspace = ?1";
133
134/// A workspace's AI and purchased credit still open: what a detach closes.
135pub(crate) const AI_GRANTS: &str =
136 "SELECT id FROM credit_grants WHERE workspace = ?1 AND closed_at IS NULL AND (kind = 'purchased' OR scope = 'models' OR source = 'purchase')";
137
138/// The plans whose subscriptions a detach may cancel at Stripe.
139pub(crate) const OPEN_PLANS: &str = "SELECT subscription_id FROM subscriptions WHERE workspace = ?1 AND status <> 'canceled'";
140
141/// What a pending row of a month before the detach is marked charged with
142/// (`charged_at`), followed by when: never charged.
143#[cfg(test)]
144const PENDING_MARK: &str = "never: test mode, detached ";
145
146/// The batch's fixed statements. `?1` the workspace, `?2` now, `?3` who,
147/// `?4` this month (`YYYY-MM`). Each changes a row only when there is
148/// something to change, so a second run counts nothing.
149pub(crate) const STATEMENTS: &[&str] = &[
150 "UPDATE subscriptions SET status = 'canceled', updated_at = ?2 WHERE workspace = ?1 AND status <> 'canceled'",
151 "UPDATE accounts SET customer_id = NULL, card_brand = NULL, card_last4 = NULL, card_exp_month = NULL, card_exp_year = NULL,
152 card_synced_at = NULL, tax_address_needed_at = NULL
153 WHERE workspace = ?1 AND (customer_id IS NOT NULL OR card_brand IS NOT NULL OR card_last4 IS NOT NULL OR card_exp_month IS NOT NULL
154 OR card_exp_year IS NOT NULL OR card_synced_at IS NOT NULL OR tax_address_needed_at IS NOT NULL)",
155 "DELETE FROM card_checks WHERE workspace = ?1",
156 "DELETE FROM checkouts WHERE workspace = ?1 AND status = 'open'",
157 "UPDATE ai_reload SET enabled = 0, updated_by = ?3, updated_at = ?2 WHERE workspace = ?1 AND enabled <> 0",
158 "UPDATE limits SET autopay_failed_at = NULL, autopay_error = NULL WHERE workspace = ?1 AND (autopay_failed_at IS NOT NULL OR autopay_error IS NOT NULL)",
159 "UPDATE workspace_invoices SET status = 'void' WHERE workspace = ?1 AND status IN ('open', 'failed')",
160 "INSERT OR IGNORE INTO plan_payments (invoice_id, workspace, amount_micros, paid_at)
161 SELECT invoice_id || '_test_detach', workspace, -amount_micros, paid_at FROM plan_payments
162 WHERE workspace = ?1 AND amount_micros > 0 AND substr(invoice_id, -12) <> '_test_detach'",
163 // Months before this one: their metered usage is never charged.
164 "UPDATE pending_usage SET charged_at = 'never: test mode, detached ' || ?2
165 WHERE workspace = ?1 AND month < ?4 AND charged_at IS NULL",
166 // This month's so far: kept as the baseline later reports are charged
167 // past (raised, never lowered, by a run after more test-mode usage).
168 "INSERT INTO pending_baselines (workspace, source, month, cost_micros, charge_micros, created_at)
169 SELECT workspace, source, month, cost_micros, charge_micros, ?2 FROM pending_usage
170 WHERE workspace = ?1 AND month = ?4 AND charged_at IS NULL AND (cost_micros > 0 OR charge_micros > 0)
171 ON CONFLICT (workspace, source, month) DO UPDATE SET
172 cost_micros = MAX(pending_baselines.cost_micros, excluded.cost_micros),
173 charge_micros = MAX(pending_baselines.charge_micros, excluded.charge_micros),
174 created_at = excluded.created_at
175 WHERE excluded.cost_micros > pending_baselines.cost_micros OR excluded.charge_micros > pending_baselines.charge_micros",
176 // This month's draws on the plan's included usage: a live plan this
177 // month starts with all of it. The trial and the pools are left.
178 "DELETE FROM allowance_use WHERE kind = 'plan_credit' AND scope = ?1 AND month = ?4",
179 // Test-mode disputes: nothing owed for them, none left open.
180 "UPDATE limits SET dispute_owed_micros = 0 WHERE workspace = ?1 AND dispute_owed_micros <> 0",
181 "UPDATE disputes SET status = 'detached', updated_at = ?2 WHERE workspace = ?1 AND status = 'open'",
182];
183
184/// A past month marked closed. `?1` workspace, `?2` month, `?3` why, `?4` now.
185pub(crate) const SKIP_MONTH: &str = "INSERT OR IGNORE INTO month_closes (workspace, month, status, amount_micros, payment_id, error, closed_at)
186 VALUES (?1, ?2, 'skipped', 0, NULL, ?3, ?4)";
187
188/// An AI credit grant closed. `?1` now, `?2` why, `?3` who, `?4` what was
189/// left, `?5` the grant.
190pub(crate) const CLOSE_GRANT: &str = "UPDATE credit_grants SET closed_at = ?1, closed_reason = 'revoked', closed_note = ?2, closed_by = ?3, closed_micros = ?4
191 WHERE id = ?5 AND closed_at IS NULL";
192
193/// A ledger line of the batch, as `Billing::enter` writes one. `?1` id,
194/// `?2` workspace, `?3` amount, `?4` description, `?5` reference, `?6` who,
195/// `?7` now, `?8` the credit's kind or NULL.
196pub(crate) const LEDGER: &str = "INSERT INTO ledger (id, workspace, kind, amount_micros, description, reference, created_by, created_at, billed_to, credit_kind)
197 VALUES (?1, ?2, 'top_up', ?3, ?4, ?5, ?6, ?7, 'g1t', ?8)";
198
199/// The balance moved with it. `?1` workspace, `?2` amount, `?3` now.
200pub(crate) const BALANCE: &str = "INSERT INTO accounts (workspace, balance_micros, created_at) VALUES (?1, ?2, ?3)
201 ON CONFLICT (workspace) DO UPDATE SET balance_micros = balance_micros + ?2";
202
203/// Every table a detach reads or writes, with the columns it names: each
204/// is checked against the migrations.
205#[cfg(test)]
206pub(crate) const USES: &[(&str, &[&str])] = &[
207 ("accounts", &[
208 "workspace", "balance_micros", "customer_id", "card_brand", "card_last4", "card_exp_month", "card_exp_year", "card_synced_at",
209 "tax_address_needed_at", "created_at",
210 ]),
211 ("subscriptions", &["workspace", "subscription_id", "status", "updated_at"]),
212 ("card_checks", &["workspace"]),
213 ("checkouts", &["workspace", "status"]),
214 ("ai_reload", &["workspace", "enabled", "updated_by", "updated_at"]),
215 ("limits", &["workspace", "autopay_failed_at", "autopay_error", "dispute_owed_micros"]),
216 ("pending_usage", &["workspace", "source", "month", "cost_micros", "charge_micros", "charged_at"]),
217 ("pending_baselines", &["workspace", "source", "month", "cost_micros", "charge_micros", "created_at"]),
218 ("allowance_use", &["kind", "scope", "month", "used"]),
219 ("disputes", &["workspace", "status", "updated_at"]),
220 ("workspace_invoices", &["workspace", "status"]),
221 ("credit_grants", &[
222 "id", "workspace", "kind", "scope", "source", "closed_at", "closed_reason", "closed_note", "closed_by", "closed_micros",
223 ]),
224 ("plan_payments", &["invoice_id", "workspace", "amount_micros", "paid_at"]),
225 ("ledger", &[
226 "id", "workspace", "kind", "amount_micros", "description", "reference", "created_by", "created_at", "billed_to", "credit_kind",
227 ]),
228 ("month_closes", &["workspace", "month", "status", "amount_micros", "payment_id", "error", "closed_at"]),
229 ("account_members", &["workspace"]),
230 ("admin_actions", &["id", "account", "action", "detail", "by", "created_at"]),
231];
232
233/// One workspace, as `FOUND` reads it.
234#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
235pub(crate) struct Found {
236 pub workspace: String,
237 pub comped: i64,
238 pub enterprise: i64,
239 pub customer: i64,
240 pub card_brand: Option<String>,
241 pub card_last4: Option<String>,
242 pub card_cached: i64,
243 pub balance: i64,
244 pub subscriptions: i64,
245 pub card_checks: i64,
246 pub checkouts: i64,
247 pub ai_reload: i64,
248 pub autopay_failed: i64,
249 pub invoices: i64,
250 pub ai_grants: i64,
251 pub paid: i64,
252 pub plan_unreversed: i64,
253 pub first_entry: Option<String>,
254 pub pending_past: i64,
255 pub pending_past_micros: i64,
256 pub pending_now: i64,
257 pub pending_now_micros: i64,
258 pub plan_credit: i64,
259 pub dispute_owed: i64,
260 pub open_disputes: i64,
261}
262
263/// An AI or purchased credit grant still open, with what is left of it.
264#[derive(Clone, Debug, PartialEq)]
265pub(crate) struct OpenGrant {
266 pub id: String,
267 pub kind: CreditKind,
268 pub left: i64,
269}
270
271impl OpenGrant {
272 /// A grant given by g1t (`crd…`) is credit; anything else was a payment.
273 fn was_paid(&self) -> bool {
274 !self.id.starts_with("crd")
275 }
276}
277
278/// Why the call is refused before anything is read, if it is.
279pub(crate) fn refusal(live: bool, has_key: bool, a: &AdminDetachTestStripeArgs) -> Option<(FailureCode, String)> {
280 if live {
281 return Some((FailureCode::Forbidden, "Billing takes real cards: test-mode data is detached before the live key, never after.".into()));
282 }
283 if a.by.trim().is_empty() {
284 return Some((FailureCode::Invalid, "Say who is detaching.".into()));
285 }
286 if a.cancel_at_stripe && !has_key {
287 return Some((FailureCode::Invalid, "Billing has no Stripe key, so nothing can be canceled at Stripe.".into()));
288 }
289 if a.dry_run {
290 return None;
291 }
292 match a.workspace.as_deref().map(|w| w.trim().to_lowercase()) {
293 Some(slug) if a.confirm.trim() != slug => {
294 Some((FailureCode::Invalid, format!("Type the workspace's slug, {slug}, exactly, to detach it.")))
295 }
296 None if a.confirm.trim() != CONFIRM_ALL => Some((FailureCode::Invalid, format!("Type {CONFIRM_ALL} to detach every workspace."))),
297 _ => None,
298 }
299}
300
301/// The exclusion list: as given, or the default.
302pub(crate) fn excluded(exclude: Option<&[String]>) -> BTreeSet<String> {
303 match exclude {
304 Some(list) => list.iter().map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty()).collect(),
305 None => DEFAULT_EXCLUDED.iter().map(|s| (*s).to_owned()).collect(),
306 }
307}
308
309/// Why a workspace is left alone. One workspace chosen by staff is left
310/// alone only when an enterprise pays for it.
311pub(crate) fn skip_reason(found: &Found, single: bool, exclude: &BTreeSet<String>) -> Option<String> {
312 if found.enterprise > 0 {
313 return Some("an enterprise pays for it: it is invoiced, never on a test card".into());
314 }
315 if single {
316 return None;
317 }
318 if exclude.contains(&found.workspace) {
319 return Some("excluded".into());
320 }
321 if found.comped > 0 {
322 return Some("comped (g1t's own)".into());
323 }
324 None
325}
326
327fn next_month(month: &str) -> String {
328 let year: i32 = month[..4].parse().unwrap_or(1970);
329 let number: u32 = month[5..7].parse().unwrap_or(1);
330 if number == 12 { format!("{}-01", year + 1) } else { format!("{year}-{:02}", number + 1) }
331}
332
333/// Every month from the first ledger line's until before `this_month` not
334/// closed yet: what the first live close could otherwise charge.
335pub(crate) fn months_to_skip(first_entry: Option<&str>, this_month: &str, closed: &BTreeSet<String>) -> Vec<String> {
336 let Some(first) = first_entry.filter(|f| f.len() >= 7) else { return vec![] };
337 let mut month = first[..7].to_owned();
338 let mut out = vec![];
339 while month.as_str() < this_month && out.len() < 600 {
340 if !closed.contains(&month) {
341 out.push(month.clone());
342 }
343 month = next_month(&month);
344 }
345 out
346}
347
348/// What a detach does to one workspace: nothing changed yet.
349pub(crate) fn plan(found: &Found, grants: &[OpenGrant], months: Vec<String>) -> WorkspaceDetach {
350 let closed: i64 = grants.iter().map(|g| g.left.max(0)).sum();
351 let closed_paid: i64 = grants.iter().filter(|g| g.was_paid()).map(|g| g.left.max(0)).sum();
352 // What test payments still add up to once the AI credit bought with
353 // them is closed: reversed, so none of it counts as paid.
354 let reversed = (found.paid - closed_paid).max(0);
355 let after = found.balance - closed - reversed;
356 let written_off = (-after).max(0);
357 let card = match (&found.card_brand, &found.card_last4) {
358 (Some(brand), Some(last4)) => Some(format!("{brand} {last4}")),
359 (None, Some(last4)) => Some(last4.clone()),
360 (Some(brand), None) => Some(brand.clone()),
361 (None, None) => None,
362 };
363 WorkspaceDetach {
364 workspace: found.workspace.clone(),
365 done: false,
366 customer: found.customer > 0,
367 card,
368 card_cached: found.card_cached > 0,
369 subscriptions: found.subscriptions as u32,
370 card_checks: found.card_checks as u32,
371 checkouts: found.checkouts as u32,
372 ai_reload: found.ai_reload > 0,
373 autopay_cleared: found.autopay_failed > 0,
374 invoices_voided: found.invoices as u32,
375 months_skipped: months,
376 credit_grants_closed: grants.len() as u32,
377 credit_closed_micros: closed,
378 payments_reversed_micros: reversed,
379 plan_payments_reversed_micros: found.plan_unreversed.max(0),
380 written_off_micros: written_off,
381 balance_before_micros: found.balance,
382 balance_after_micros: after + written_off,
383 stripe_canceled: 0,
384 stripe_errors: vec![],
385 pending_months_marked: found.pending_past.max(0) as u32,
386 pending_marked_micros: found.pending_past_micros.max(0),
387 pending_baselined: found.pending_now.max(0) as u32,
388 pending_baselined_micros: found.pending_now_micros.max(0),
389 plan_credit_cleared_micros: found.plan_credit.max(0),
390 dispute_owed_cleared_micros: found.dispute_owed.max(0),
391 disputes_closed: found.open_disputes.max(0) as u32,
392 }
393}
394
395/// Whether a detach would change anything.
396pub(crate) fn changes(d: &WorkspaceDetach) -> bool {
397 d.customer
398 || d.card_cached
399 || d.subscriptions > 0
400 || d.card_checks > 0
401 || d.checkouts > 0
402 || d.ai_reload
403 || d.autopay_cleared
404 || d.invoices_voided > 0
405 || !d.months_skipped.is_empty()
406 || d.credit_grants_closed > 0
407 || d.payments_reversed_micros > 0
408 || d.plan_payments_reversed_micros > 0
409 || d.written_off_micros > 0
410 || d.pending_months_marked > 0
411 || d.pending_baselined > 0
412 || d.plan_credit_cleared_micros > 0
413 || d.dispute_owed_cleared_micros > 0
414 || d.disputes_closed > 0
415}
416
417/// A value to bind, apart from JavaScript so the batch can be tested.
418#[derive(Clone, Debug, PartialEq)]
419pub(crate) enum Bind {
420 Text(String),
421 Number(i64),
422 Null,
423}
424
425impl Bind {
426 fn js(&self) -> JsValue {
427 match self {
428 Bind::Text(text) => text.as_str().into(),
429 Bind::Number(n) => (*n as f64).into(),
430 Bind::Null => JsValue::NULL,
431 }
432 }
433}
434
435fn text(s: &str) -> Bind {
436 Bind::Text(s.to_owned())
437}
438
439/// The batch for one workspace, every statement with its values. `ids`
440/// makes each ledger line's id.
441pub(crate) fn batch(
442 d: &WorkspaceDetach,
443 grants: &[OpenGrant],
444 at: &str,
445 by: &str,
446 this_month: &str,
447 mut ids: impl FnMut() -> String,
448) -> Vec<(&'static str, Vec<Bind>)> {
449 let ws = d.workspace.as_str();
450 let mut out: Vec<(&'static str, Vec<Bind>)> = vec![];
451 let fixed = [text(ws), text(at), text(by), text(this_month)];
452 for sql in STATEMENTS {
453 out.push((sql, fixed[..crate::rename::parameters(sql)].to_vec()));
454 }
455 for month in &d.months_skipped {
456 out.push((SKIP_MONTH, vec![text(ws), text(month), text(MONTH_NOTE), text(at)]));
457 }
458 let mut line = |out: &mut Vec<(&'static str, Vec<Bind>)>, amount: i64, description: String, reference: String, kind: Option<CreditKind>| {
459 out.push((
460 LEDGER,
461 vec![
462 Bind::Text(ids()),
463 text(ws),
464 Bind::Number(amount),
465 Bind::Text(description),
466 Bind::Text(reference),
467 text(by),
468 text(at),
469 kind.map_or(Bind::Null, |k| text(k.as_str())),
470 ],
471 ));
472 out.push((BALANCE, vec![text(ws), Bind::Number(amount), text(at)]));
473 };
474 for grant in grants {
475 let left = grant.left.max(0);
476 let why = if grant.was_paid() {
477 "Bought while payments were in test mode; closed before live billing"
478 } else {
479 "AI credit can buy nothing since hosted agents were removed; closed before live billing"
480 };
481 out.push((CLOSE_GRANT, vec![text(at), text(why), text(by), Bind::Number(left), text(&grant.id)]));
482 if left > 0 {
483 // `<grant>_revoked`: the replay reads the grant as closed by it.
484 line(&mut out, -left, format!("AI credit closed before live billing: {} unused", cents(left)), format!("{}_revoked", grant.id), Some(grant.kind));
485 }
486 }
487 if d.payments_reversed_micros > 0 {
488 line(
489 &mut out,
490 -d.payments_reversed_micros,
491 format!("Test-mode payments reversed before live billing: {}", cents(d.payments_reversed_micros)),
492 format!("test_detach/{ws}/{at}"),
493 None,
494 );
495 }
496 out
497}
498
499/// What the audit log says a detach changed.
500pub(crate) fn describe(d: &WorkspaceDetach) -> String {
501 let mut parts = vec![];
502 if d.customer || d.card_cached {
503 let card = d.card.as_deref().map(|c| format!(", card {c}")).unwrap_or_default();
504 parts.push(format!("customer{card} forgotten"));
505 }
506 let count = |n: u32, one: &str, many: &str| if n == 1 { format!("1 {one}") } else { format!("{n} {many}") };
507 if d.subscriptions > 0 {
508 parts.push(format!("{} canceled", count(d.subscriptions, "plan", "plans")));
509 }
510 if d.card_checks > 0 {
511 parts.push(format!("{} removed", count(d.card_checks, "card check", "card checks")));
512 }
513 if d.checkouts > 0 {
514 parts.push(format!("{} removed", count(d.checkouts, "open checkout", "open checkouts")));
515 }
516 if d.ai_reload {
517 parts.push("AI auto-reload off".into());
518 }
519 if d.autopay_cleared {
520 parts.push("declined autopay cleared".into());
521 }
522 if d.invoices_voided > 0 {
523 parts.push(format!("{} void", count(d.invoices_voided, "invoice", "invoices")));
524 }
525 if !d.months_skipped.is_empty() {
526 parts.push(format!("months {} closed as skipped", d.months_skipped.join(", ")));
527 }
528 if d.credit_grants_closed > 0 {
529 parts.push(format!("{} closed ({} unused)", count(d.credit_grants_closed, "AI credit grant", "AI credit grants"), cents(d.credit_closed_micros)));
530 }
531 if d.payments_reversed_micros > 0 {
532 parts.push(format!("{} of test payments reversed", cents(d.payments_reversed_micros)));
533 }
534 if d.plan_payments_reversed_micros > 0 {
535 parts.push(format!("{} of test plan payments reversed", cents(d.plan_payments_reversed_micros)));
536 }
537 if d.written_off_micros > 0 {
538 parts.push(format!("{} owed written off as goodwill", cents(d.written_off_micros)));
539 }
540 if d.pending_months_marked > 0 {
541 parts.push(format!(
542 "{} of past months never charged ({})",
543 count(d.pending_months_marked, "metered figure", "metered figures"),
544 cents(d.pending_marked_micros)
545 ));
546 }
547 if d.pending_baselined > 0 {
548 parts.push(format!(
549 "this month's {} kept as baseline, never charged ({})",
550 count(d.pending_baselined, "metered figure", "metered figures"),
551 cents(d.pending_baselined_micros)
552 ));
553 }
554 if d.plan_credit_cleared_micros > 0 {
555 parts.push(format!("{} of this month's included usage restored", cents(d.plan_credit_cleared_micros)));
556 }
557 if d.dispute_owed_cleared_micros > 0 {
558 parts.push(format!("{} owed for lost test disputes cleared", cents(d.dispute_owed_cleared_micros)));
559 }
560 if d.disputes_closed > 0 {
561 parts.push(format!("{} closed", count(d.disputes_closed, "open test dispute", "open test disputes")));
562 }
563 if d.stripe_canceled > 0 {
564 parts.push(format!("{} canceled at Stripe", count(d.stripe_canceled, "subscription", "subscriptions")));
565 }
566 for error in &d.stripe_errors {
567 parts.push(format!("Stripe: {error}"));
568 }
569 format!(
570 "test-mode Stripe data detached from {}: {}; balance {} to {}",
571 d.workspace,
572 parts.join("; "),
573 cents(d.balance_before_micros),
574 cents(d.balance_after_micros)
575 )
576}
577
578impl Billing {
579 pub(crate) async fn admin_detach_test_stripe(&self, a: AdminDetachTestStripeArgs) -> Result<Outcome<TestStripeDetach>> {
580 let live = self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live);
581 if let Some((code, why)) = refusal(live, self.stripe.is_some(), &a) {
582 return Ok(Outcome::fail(code, why));
583 }
584 let single = a.workspace.as_deref().map(|w| w.trim().to_lowercase()).filter(|w| !w.is_empty());
585 if a.workspace.is_some() && single.is_none() {
586 return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace, or none for every workspace."));
587 }
588 let exclude = excluded(a.exclude.as_deref());
589 let by = a.by.trim().to_owned();
590 let target = single.as_deref().map_or(JsValue::NULL, JsValue::from);
591 let this_month = rfc3339(now_ms())[..7].to_owned();
592 let found = self
593 .db
594 .prepare(FOUND.replace("{internal}", crate::sales::INTERNAL_SQL))
595 .bind(&[target.clone(), this_month.as_str().into()])?
596 .all()
597 .await?
598 .results::<Found>()?;
599 #[derive(Deserialize)]
600 struct Closed {
601 workspace: String,
602 month: String,
603 }
604 let mut closed: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
605 for row in self.db.prepare(CLOSED).bind(&[target])?.all().await?.results::<Closed>()? {
606 closed.entry(row.workspace).or_default().insert(row.month);
607 }
608 let none = BTreeSet::new();
609
610 let mut out = TestStripeDetach { dry_run: a.dry_run, ..TestStripeDetach::default() };
611 let mut todo: Vec<(WorkspaceDetach, Vec<OpenGrant>)> = vec![];
612 for row in &found {
613 let grants = if row.ai_grants > 0 { self.open_ai_grants(&row.workspace).await? } else { vec![] };
614 let months = months_to_skip(row.first_entry.as_deref(), &this_month, closed.get(&row.workspace).unwrap_or(&none));
615 let detach = plan(row, &grants, months);
616 if !changes(&detach) {
617 continue;
618 }
619 if let Some(reason) = skip_reason(row, single.is_some(), &exclude) {
620 if single.is_some() {
621 return Ok(Outcome::fail(FailureCode::Forbidden, format!("{}: {reason}.", row.workspace)));
622 }
623 out.skipped.push(DetachSkipped { workspace: row.workspace.clone(), reason });
624 continue;
625 }
626 todo.push((detach, grants));
627 }
628 if a.dry_run {
629 out.workspaces = todo.into_iter().map(|(d, _)| d).collect();
630 return Ok(Outcome::Ok(out));
631 }
632 out.remaining = todo.len().saturating_sub(PER_CALL) as u32;
633 for (mut detach, grants) in todo.into_iter().take(PER_CALL) {
634 if a.cancel_at_stripe {
635 self.cancel_test_plans(&mut detach).await?;
636 }
637 let at_ms = now_ms();
638 let at = rfc3339(at_ms);
639 let statements = batch(&detach, &grants, &at, &by, &this_month, || new_id("led", at_ms));
640 let mut prepared = Vec::with_capacity(statements.len());
641 for (sql, values) in &statements {
642 let values: Vec<JsValue> = values.iter().map(Bind::js).collect();
643 prepared.push(self.db.prepare(*sql).bind(&values)?);
644 }
645 self.db.batch(prepared).await?;
646 if detach.written_off_micros > 0 {
647 self.grant_credit(&detach.workspace, CreditKind::Goodwill, detach.written_off_micros, WRITE_OFF_NOTE, &by, None, None, None, None, None)
648 .await?;
649 }
650 detach.done = true;
651 self.audit(&own_account(&detach.workspace), "test_detach", &describe(&detach), &by).await?;
652 out.workspaces.push(detach);
653 }
654 Ok(Outcome::Ok(out))
655 }
656
657 /// The workspace's open AI and purchased credit, with what is left.
658 async fn open_ai_grants(&self, workspace: &str) -> Result<Vec<OpenGrant>> {
659 #[derive(Deserialize)]
660 struct Id {
661 id: String,
662 }
663 let ids: BTreeSet<String> =
664 self.db.prepare(AI_GRANTS).bind(&[workspace.into()])?.all().await?.results::<Id>()?.into_iter().map(|r| r.id).collect();
665 if ids.is_empty() {
666 return Ok(vec![]);
667 }
668 let credits = self.credits_of(workspace).await?;
669 Ok(credits
670 .grants
671 .into_iter()
672 .filter(|g| ids.contains(&g.id))
673 // What the ledger still holds of it, expired or not.
674 .map(|g| OpenGrant { left: (g.amount_micros - g.used_micros).max(0), id: g.id, kind: g.kind })
675 .collect())
676 }
677
678 /// Cancels the workspace's plans at Stripe, on the test key only (the
679 /// call is refused on a live one before this).
680 async fn cancel_test_plans(&self, detach: &mut WorkspaceDetach) -> Result<()> {
681 let Some(stripe) = self.stripe.as_ref().filter(|s| !s.live()) else {
682 return Ok(());
683 };
684 #[derive(Deserialize)]
685 struct Plan {
686 subscription_id: String,
687 }
688 let plans = self.db.prepare(OPEN_PLANS).bind(&[detach.workspace.as_str().into()])?.all().await?.results::<Plan>()?;
689 for plan in plans {
690 match stripe.cancel_now(&plan.subscription_id).await {
691 Ok(_) => detach.stripe_canceled += 1,
692 // Gone at Stripe already: nothing to cancel.
693 Err(error) if crate::stripe::is_missing(&error) => {}
694 Err(error) => detach.stripe_errors.push(format!("{}: {}", plan.subscription_id, crate::stripe::friendly(&error))),
695 }
696 }
697 Ok(())
698 }
699}
700
701#[cfg(test)]
702mod tests {
703 use super::*;
704
705 fn args(workspace: Option<&str>, confirm: &str, dry_run: bool) -> AdminDetachTestStripeArgs {
706 AdminDetachTestStripeArgs {
707 workspace: workspace.map(Into::into),
708 confirm: confirm.into(),
709 dry_run,
710 by: "staff@g1t.sh".into(),
711 ..Default::default()
712 }
713 }
714
715 /// Each table the migrations leave, with its columns.
716 fn schema() -> BTreeMap<String, BTreeSet<String>> {
717 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("migrations");
718 let mut files: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().path()).collect();
719 files.sort();
720 let mut tables: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
721 for file in files {
722 let sql: String = std::fs::read_to_string(file)
723 .unwrap()
724 .lines()
725 .map(|l| l.split("--").next().unwrap_or(""))
726 .collect::<Vec<_>>()
727 .join("\n");
728 for statement in sql.split(';') {
729 let words: Vec<&str> = statement.split_whitespace().collect();
730 let s = words.join(" ");
731 let create = s.strip_prefix("CREATE TABLE IF NOT EXISTS ").or_else(|| s.strip_prefix("CREATE TABLE "));
732 if let Some(rest) = create {
733 let Some(open) = rest.find('(') else { continue };
734 let name = rest[..open].trim().to_owned();
735 let body = &rest[open + 1..rest.rfind(')').unwrap_or(rest.len())];
736 // Split on commas outside parentheses.
737 let (mut depth, mut part, mut columns) = (0, String::new(), BTreeSet::new());
738 for ch in body.chars().chain(std::iter::once(',')) {
739 match ch {
740 '(' => depth += 1,
741 ')' => depth -= 1,
742 ',' if depth == 0 => {
743 let word = part.split_whitespace().next().unwrap_or("").to_owned();
744 if !word.is_empty() && !["PRIMARY", "UNIQUE", "FOREIGN", "CHECK"].contains(&word.as_str()) {
745 columns.insert(word);
746 }
747 part.clear();
748 continue;
749 }
750 _ => {}
751 }
752 part.push(ch);
753 }
754 tables.insert(name, columns);
755 } else if let Some(rest) = s.strip_prefix("DROP TABLE IF EXISTS ").or_else(|| s.strip_prefix("DROP TABLE ")) {
756 tables.remove(rest.trim());
757 } else if let Some(rest) = s.strip_prefix("ALTER TABLE ") {
758 let words: Vec<&str> = rest.split_whitespace().collect();
759 if let [table, "ADD", "COLUMN", column, ..] = words.as_slice() {
760 tables.entry((*table).to_owned()).or_default().insert((*column).to_owned());
761 }
762 }
763 }
764 }
765 tables
766 }
767
768 fn every_sql() -> Vec<String> {
769 let mut all: Vec<String> = STATEMENTS.iter().map(|s| s.to_string()).collect();
770 all.extend([FOUND, CLOSED, AI_GRANTS, OPEN_PLANS, SKIP_MONTH, CLOSE_GRANT, LEDGER, BALANCE].map(String::from));
771 all
772 }
773
774 #[test]
775 fn every_table_and_column_is_one_the_migrations_leave() {
776 let schema = schema();
777 assert!(schema["ledger"].contains("credit_kind") && schema["accounts"].contains("tax_address_needed_at"));
778 for (table, columns) in USES {
779 let have = schema.get(*table).unwrap_or_else(|| panic!("{table} is not a table after the migrations"));
780 for column in *columns {
781 assert!(have.contains(*column), "{table}.{column} is not a column after the migrations");
782 }
783 }
784 // Every table the SQL names is in USES, and every column in USES
785 // is named somewhere, so the list cannot go stale.
786 let all = every_sql().join("\n");
787 for table in schema.keys() {
788 let named = [" FROM ", " INTO ", "UPDATE "].iter().any(|p| all.contains(&format!("{p}{table} ")) || all.contains(&format!("{p}{table}\n")));
789 if named {
790 assert!(USES.iter().any(|(t, _)| t == table), "{table} is used but not in USES");
791 }
792 }
793 for (table, columns) in USES {
794 for column in *columns {
795 assert!(all.contains(column) || *table == "admin_actions", "{table}.{column} is listed but never named");
796 }
797 }
798 }
799
800 #[test]
801 fn nothing_is_deleted_but_card_checks_open_checkouts_and_this_months_plan_draws() {
802 let all = every_sql().join("\n");
803 assert_eq!(all.matches("DELETE FROM").count(), 3);
804 assert!(all.contains("DELETE FROM card_checks WHERE workspace = ?1"));
805 assert!(all.contains("DELETE FROM checkouts WHERE workspace = ?1 AND status = 'open'"));
806 // Only the plan's included usage, only this month: the trial and
807 // the open-source pool's draws stay.
808 assert!(all.contains("DELETE FROM allowance_use WHERE kind = 'plan_credit' AND scope = ?1 AND month = ?4"));
809 // The trial grant stays: no second trial.
810 assert!(!all.contains("trial_grants"));
811 assert_eq!(PLAN_REVERSAL.len(), 12);
812 assert!(all.matches("'_test_detach'").count() >= 3);
813 }
814
815 #[test]
816 fn refused_on_a_live_key_and_without_confirmation() {
817 let one = args(Some("acme"), "acme", false);
818 assert_eq!(refusal(true, true, &one).unwrap().0, FailureCode::Forbidden);
819 assert_eq!(refusal(true, true, &args(None, "", true)).unwrap().0, FailureCode::Forbidden);
820 assert!(refusal(false, true, &one).is_none());
821 assert!(refusal(false, false, &one).is_none());
822 assert!(refusal(false, true, &args(Some("acme"), "acm", false)).is_some());
823 assert!(refusal(false, true, &args(None, "acme", false)).is_some());
824 assert!(refusal(false, true, &args(None, CONFIRM_ALL, false)).is_none());
825 // A dry run needs no confirmation.
826 assert!(refusal(false, true, &args(None, "", true)).is_none());
827 // Canceling at Stripe needs a (test) key.
828 let cancel = AdminDetachTestStripeArgs { cancel_at_stripe: true, ..args(None, CONFIRM_ALL, false) };
829 assert!(refusal(false, false, &cancel).is_some());
830 assert!(refusal(false, true, &cancel).is_none());
831 let nobody = AdminDetachTestStripeArgs { by: " ".into(), ..one };
832 assert!(refusal(false, true, &nobody).is_some());
833 }
834
835 #[test]
836 fn every_workspace_skips_comped_enterprise_and_excluded() {
837 let found = |slug: &str, comped: i64, enterprise: i64| Found { workspace: slug.into(), comped, enterprise, ..Found::default() };
838 let defaults = excluded(None);
839 assert_eq!(defaults, ["flagon-io", "syntaqx"].iter().map(|s| s.to_string()).collect());
840 assert!(skip_reason(&found("syntaqx", 0, 0), false, &defaults).is_some());
841 assert!(skip_reason(&found("flagon-io", 1, 0), false, &defaults).is_some());
842 assert!(skip_reason(&found("acme", 1, 0), false, &defaults).is_some());
843 assert!(skip_reason(&found("acme", 0, 1), false, &defaults).is_some());
844 assert!(skip_reason(&found("acme", 0, 0), false, &defaults).is_none());
845 // An empty list excludes nothing; a given one replaces the default.
846 let given = excluded(Some(&[" Acme ".to_string(), String::new()]));
847 assert_eq!(given.len(), 1);
848 assert!(skip_reason(&found("syntaqx", 0, 0), false, &given).is_none());
849 assert!(skip_reason(&found("acme", 0, 0), false, &given).is_some());
850 // One workspace chosen by staff: anything but an enterprise's.
851 assert!(skip_reason(&found("syntaqx", 0, 0), true, &defaults).is_none());
852 assert!(skip_reason(&found("flagon-io", 1, 0), true, &defaults).is_none());
853 assert!(skip_reason(&found("acme", 0, 1), true, &defaults).is_some());
854 }
855
856 #[test]
857 fn every_past_month_not_closed_is_skipped() {
858 let closed: BTreeSet<String> = ["2026-09".to_string()].into();
859 assert_eq!(months_to_skip(Some("2026-07-14T10:00:00.000Z"), "2026-10", &closed), ["2026-07", "2026-08"]);
860 assert_eq!(months_to_skip(Some("2025-11-30T00:00:00Z"), "2026-02", &BTreeSet::new()), ["2025-11", "2025-12", "2026-01"]);
861 assert!(months_to_skip(Some("2026-10-01T00:00:00Z"), "2026-10", &BTreeSet::new()).is_empty());
862 assert!(months_to_skip(None, "2026-10", &BTreeSet::new()).is_empty());
863 }
864
865 /// A workspace that paid $50 in test mode, bought $20 of AI credit
866 /// ($5 used), used $30 of other things, and has a card, a plan and an
867 /// open invoice.
868 fn tested() -> (Found, Vec<OpenGrant>) {
869 let found = Found {
870 workspace: "acme".into(),
871 customer: 1,
872 card_brand: Some("visa".into()),
873 card_last4: Some("4242".into()),
874 card_cached: 1,
875 balance: 35_000_000,
876 subscriptions: 1,
877 card_checks: 1,
878 checkouts: 1,
879 invoices: 1,
880 ai_grants: 1,
881 paid: 70_000_000,
882 plan_unreversed: 20_000_000,
883 first_entry: Some("2026-09-02T00:00:00Z".into()),
884 ..Found::default()
885 };
886 (found, vec![OpenGrant { id: "cs_test_ai".into(), kind: CreditKind::Purchased, left: 15_000_000 }])
887 }
888
889 #[test]
890 fn the_balance_comes_to_zero_and_nothing_counts_as_paid() {
891 let (found, grants) = tested();
892 let d = plan(&found, &grants, vec!["2026-09".into()]);
893 assert!(changes(&d));
894 assert_eq!(d.card.as_deref(), Some("visa 4242"));
895 assert_eq!(d.credit_closed_micros, 15_000_000);
896 // 70 paid less the 15 of AI credit closed: 55 reversed.
897 assert_eq!(d.payments_reversed_micros, 55_000_000);
898 // 35 - 15 - 55 = -35 owed: written off.
899 assert_eq!(d.written_off_micros, 35_000_000);
900 assert_eq!(d.balance_after_micros, 0);
901 assert_eq!(d.plan_payments_reversed_micros, 20_000_000);
902 // Paid ledger lines after: 70 - 15 - 55 = 0.
903 let statements = batch(&d, &grants, "2026-10-13T00:00:00.000Z", "staff@g1t.sh", "2026-10", || "led_x".into());
904 let moved: i64 = statements
905 .iter()
906 .filter(|(sql, _)| *sql == LEDGER)
907 .map(|(_, v)| match v[2] { Bind::Number(n) => n, _ => 0 })
908 .sum();
909 assert_eq!(found.balance + moved + d.written_off_micros, 0);
910 assert!(statements.iter().any(|(sql, v)| *sql == LEDGER && v[4] == text("cs_test_ai_revoked")));
911 assert!(statements.iter().any(|(sql, v)| *sql == SKIP_MONTH && v[1] == text("2026-09")));
912 // Every statement gets exactly the values it names.
913 for (sql, values) in &statements {
914 assert_eq!(crate::rename::parameters(sql), values.len(), "{sql}");
915 }
916 assert!(describe(&d).contains("$35.00 owed written off as goodwill"));
917 }
918
919 #[test]
920 fn staff_credit_left_is_kept() {
921 // $10 promotional with $5 left, $50 paid, $5 used: balance 55.
922 let found = Found { workspace: "acme".into(), balance: 55_000_000, paid: 50_000_000, ..Found::default() };
923 let d = plan(&found, &[], vec![]);
924 assert_eq!(d.payments_reversed_micros, 50_000_000);
925 assert_eq!(d.written_off_micros, 0);
926 assert_eq!(d.balance_after_micros, 5_000_000);
927 }
928
929 #[test]
930 fn a_second_run_changes_nothing() {
931 // After a detach FOUND reads: no customer or card, plans canceled,
932 // no checks or open checkouts, invoices void, AI credit closed,
933 // payments netting to nothing, plan payments reversed, and every
934 // past month closed.
935 let (found, grants) = tested();
936 let first = plan(&found, &grants, months_to_skip(found.first_entry.as_deref(), "2026-10", &BTreeSet::new()));
937 let closed: BTreeSet<String> = first.months_skipped.iter().cloned().collect();
938 let after = Found {
939 workspace: "acme".into(),
940 balance: first.balance_after_micros,
941 paid: found.paid - first.credit_closed_micros - first.payments_reversed_micros,
942 first_entry: found.first_entry.clone(),
943 ..Found::default()
944 };
945 let second = plan(&after, &[], months_to_skip(after.first_entry.as_deref(), "2026-10", &closed));
946 assert!(!changes(&second), "{second:?}");
947 assert!(batch(&second, &[], "t", "b", "2026-10", || "x".into()).iter().all(|(sql, _)| STATEMENTS.contains(sql)));
948 // Refunds beyond what was paid are never reversed again.
949 let refunded = Found { workspace: "acme".into(), paid: -5_000_000, ..Found::default() };
950 assert!(!changes(&plan(&refunded, &[], vec![])));
951 }
952
953 #[test]
954 fn metered_usage_from_test_mode_is_never_charged_live() {
955 // Past months' rows are marked charged, this month's kept as the
956 // baseline, both in the batch and only for the workspace.
957 let past = STATEMENTS.iter().find(|s| s.starts_with("UPDATE pending_usage")).unwrap();
958 assert!(past.contains(&format!("'{PENDING_MARK}' || ?2")) && past.contains("month < ?4") && past.contains("charged_at IS NULL"));
959 let base = STATEMENTS.iter().find(|s| s.contains("INTO pending_baselines")).unwrap();
960 assert!(base.contains("month = ?4") && base.contains("MAX(pending_baselines.cost_micros, excluded.cost_micros)"));
961 // Raised only when there is more: a second run writes nothing.
962 assert!(base.contains("WHERE excluded.cost_micros > pending_baselines.cost_micros"));
963 assert!(FOUND.contains("AS pending_now") && FOUND.contains("u.cost_micros > COALESCE(b.cost_micros, 0)"));
964 let found = Found {
965 workspace: "acme".into(),
966 pending_past: 2,
967 pending_past_micros: 1_200_000,
968 pending_now: 1,
969 pending_now_micros: 300_000,
970 plan_credit: 4_000_000,
971 dispute_owed: 46_400_000,
972 open_disputes: 1,
973 ..Found::default()
974 };
975 let d = plan(&found, &[], vec![]);
976 assert!(changes(&d));
977 assert_eq!((d.pending_months_marked, d.pending_baselined, d.disputes_closed), (2, 1, 1));
978 assert_eq!((d.plan_credit_cleared_micros, d.dispute_owed_cleared_micros), (4_000_000, 46_400_000));
979 let statements = batch(&d, &[], "2026-10-13T00:00:00.000Z", "staff@g1t.sh", "2026-10", || "led_x".into());
980 for (sql, values) in &statements {
981 assert_eq!(crate::rename::parameters(sql), values.len(), "{sql}");
982 if crate::rename::parameters(sql) == 4 {
983 assert_eq!(values[3], text("2026-10"), "{sql}");
984 }
985 }
986 let text = describe(&d);
987 assert!(text.contains("2 metered figures of past months never charged ($1.20)"), "{text}");
988 assert!(text.contains("$4.00 of this month's included usage restored"), "{text}");
989 // What a run leaves: nothing more to do.
990 assert!(!changes(&plan(&Found { workspace: "acme".into(), ..Found::default() }, &[], vec![])));
991 }
992
993 #[test]
994 fn test_disputes_owe_nothing_and_hold_nothing() {
995 let all = STATEMENTS.join("\n");
996 assert!(all.contains("UPDATE limits SET dispute_owed_micros = 0 WHERE workspace = ?1 AND dispute_owed_micros <> 0"));
997 assert!(all.contains("UPDATE disputes SET status = 'detached', updated_at = ?2 WHERE workspace = ?1 AND status = 'open'"));
998 // A detached dispute is never acted on again.
999 assert!(!crate::webhooks::dispute_moves(Some("detached"), "lost"));
1000 assert!(!crate::webhooks::dispute_moves(Some("detached"), "open"));
1001 // And it no longer holds the stop a dispute made.
1002 assert!(crate::CLEAR_STOP.contains("disputes.status = 'open'"));
1003 }
1004
1005 #[test]
1006 fn a_test_plan_payment_is_no_evidence_of_the_plan() {
1007 let evidence = crate::storage::PLAN_EVIDENCE;
1008 assert!(evidence.contains("p.amount_micros > 0"));
1009 assert!(evidence.contains("substr(p.invoice_id, -12) <> '_test_detach'"));
1010 assert!(evidence.contains("q.invoice_id = p.invoice_id || '_test_detach'"));
1011 assert!(evidence.contains("), 0) > 0)"));
1012 }
1013}