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.

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