Commit

sudo: reset a test workspace's billing so it starts again as a new customer; refused on a live Stripe key, for comped workspaces and for an enterprise's

syntaqxcommitted Parentea16398Browse files
11 files+250−10/11 viewed
+43−1
33 * confirmations, terms, plan, pools and caps, credit, bank transfers, the
44 * Stripe billing link, the ledger and the audit log. Plain forms; sudo ships no JavaScript.
55 */
6−import { CreditCard, Gift, Landmark, ScrollText, UserRound } from "lucide-react";
6+import { CreditCard, Gift, Landmark, RotateCcw, ScrollText, UserRound } from "lucide-react";
77 import type { ReactNode } from "react";
88 import { Link } from "react-router";
99
512512 );
513513 }
514514
515+// --- Reset (testing) -----------------------------------------------------------
516+
517+/**
518+ * Wipes a test workspace's billing so it starts again as a new customer.
519+ * Only while billing runs on Stripe's test key; billing refuses comped and
520+ * enterprise workspaces. The workspace, its members and repositories stay.
521+ */
522+export function ResetBillingForm({ workspace, pathname, error }: { workspace: string; pathname: string; error: SectionError }) {
523+ const values = error?.values;
524+ return (
525+ <Section
526+ id="reset"
527+ title="Reset billing (testing)"
528+ description="Wipes this workspace's billing: its ledger and balance, plan, limits, trial, invoices, holds, signals and cost rows. The workspace, its members and its repositories stay. Only while billing is on Stripe's test key."
529+ >
530+ <form method="post" action={`${pathname}#reset`} className="space-y-4">
531+ <input type="hidden" name="intent" value="reset" />
532+ {error && <Notice tone="error">{error.error}</Notice>}
533+ <Field label="Why" hint="Required. Kept in the audit log.">
534+ <Textarea name="note" rows={2} required maxLength={500} placeholder="e.g. Test workspace, starting the customer walk-through again" defaultValue={values?.note ?? ""} />
535+ </Field>
536+ <Field
537+ label="Confirm"
538+ hint={
539+ <>
540+ Type the workspace's slug (<span className="font-mono text-muted">{workspace}</span>) to wipe its billing. It cannot be undone.
541+ </>
542+ }
543+ >
544+ <Input name="confirmation" required placeholder={workspace} className="font-mono" />
545+ </Field>
546+ <div className="flex justify-end">
547+ <Button type="submit" variant="danger">
548+ <RotateCcw size={14} />
549+ Reset billing
550+ </Button>
551+ </div>
552+ </form>
553+ </Section>
554+ );
555+}
556+
515557 // --- Stripe billing link -----------------------------------------------------
516558
517559 /**
+15−0
121121 return back("credit");
122122 }
123123
124+ if (intent === "reset") {
125+ // A test workspace's billing wiped. Billing refuses it on a live Stripe
126+ // key, for comped workspaces and for an enterprise's.
127+ const values = fields(form, "note", "confirmation");
128+ if (subject.kind !== "workspace") return failed("top", "Reset a workspace, not an enterprise.");
129+ const note = parseNote(values.note);
130+ if (!note.ok) return failed("reset", note.error, values);
131+ if (values.confirmation !== subject.slug) {
132+ return failed("reset", `Type the workspace's slug, ${subject.slug}, exactly, to reset it.`, { ...values, confirmation: "" });
133+ }
134+ const result = await admin.resetBilling(subject.slug, values.confirmation, note.value, staff.email);
135+ if (!result.ok) return failed("reset", result.error.message, values);
136+ return back("reset");
137+ }
138+
124139 if (intent === "payment") {
125140 // A bank transfer that reached g1t outside Stripe's page.
126141 const values = fields(form, "amount", "reference", "note", "confirmation");
+1−0
9797 attach: "Workspace added",
9898 detach: "Workspace removed",
9999 credit: "Credit issued",
100+ reset: "Billing reset (testing)",
100101 billing_link: "Billing link made",
101102 billing_email: "Invoice email set",
102103 invoice: "Invoice sent",
+1−0
2828 attach: "Workspace moved onto the enterprise.",
2929 detach: "Workspace moved off the enterprise. It pays for itself again.",
3030 credit: "Credit issued.",
31+ reset: "Billing reset. The workspace starts again as a new customer; run the costs analysis to redo the margin figures.",
3132 created: "Enterprise created.",
3233 "billing-email": "Saved where the enterprise's invoices go.",
3334 sales: "Sales record saved.",
+2−0
1414 LedgerSection,
1515 Owners,
1616 PaymentForm,
17+ ResetBillingForm,
1718 ReviewPanel,
1819 TermsForm,
1920 } from "~/components/billing";
355356 )}
356357 <PaymentForm workspace={slug} pathname={pathname} error={error("payment")} />
357358 <CreditForm workspaces={[slug]} pathname={pathname} error={error("credit")} />
359+ {!billedTo && terms.kind !== "comped" && <ResetBillingForm workspace={slug} pathname={pathname} error={error("reset")} />}
358360 <div id="audit" className="scroll-mt-20">
359361 <AuditSection
360362 audit={audit}
+20−0
21542154 pub by: String,
21552155 }
21562156
2157+/// `admin_reset_billing`: a test workspace's billing wiped, so it starts
2158+/// again as a new customer. Only while billing runs on Stripe's test key;
2159+/// never a comped workspace or one an enterprise pays for. `confirm` is the
2160+/// workspace's slug typed out. Returns `Outcome<BillingReset>`.
2161+#[derive(Debug, Serialize, Deserialize)]
2162+pub struct AdminResetBillingArgs {
2163+ pub workspace: String,
2164+ pub confirm: String,
2165+ pub note: String,
2166+ pub by: String,
2167+}
2168+
2169+/// What a reset removed.
2170+#[derive(Clone, Debug, Serialize, Deserialize)]
2171+#[serde(rename_all = "camelCase")]
2172+pub struct BillingReset {
2173+ pub workspace: String,
2174+ pub rows: u32,
2175+}
2176+
21572177 /// One change made in sudo.
21582178 #[derive(Clone, Debug, Serialize, Deserialize)]
21592179 #[serde(rename_all = "camelCase")]
+19−0
346346 - **Change a default**: edit the variable in `services/billing/wrangler.jsonc`
347347 and deploy g1t-billing.
348348
349+## Resetting a test workspace
350+
351+sudo → the workspace → **Reset billing (testing)** (`admin_reset_billing`)
352+returns a workspace used for testing to how a new customer starts. It
353+deletes the workspace's rows from every billing table: ledger and balance,
354+plan and plan payments, limits and limit requests, trial grant, invoices,
355+holds, card checks, alerts sent, price notices, month-end snapshots and
356+closes, storage and sandbox meters, token usage, spikes, sales records and
357+notes, `workspace_costs`, its workspace margin alert and its own billing
358+account. It keeps `own_counts` (what Cloudflare's bill is compared with)
359+and the audit log, which records the reset with the note and the number of
360+rows. The workspace, its members and its repositories are identity's and
361+repos' and stay.
362+
363+Billing refuses it while `STRIPE_SECRET_KEY` is a live key, for comped
364+workspaces, and for a workspace an enterprise pays for. Afterwards press
365+**Run the analysis now** on Costs & margin so the margin figures drop the
366+workspace's past usage.
367+
349368 ## Stripe
350369
351370 Billing keeps what it needs from Stripe so reads never wait on it, and
+5−0
524524 createEnterprise(name: string, workspaces: string[], by: string): Promise<Result<PayingAccount>>;
525525 attach(workspace: string, account: string | null, by: string): Promise<Result<PayingAccount>>;
526526 credit(workspace: string, amountMicros: number, note: string, by: string): Promise<Result<LedgerEntry>>;
527+ /** A test workspace's billing wiped, to start again as a new customer. Only on Stripe's test key; never comped or enterprise. Logged. */
528+ resetBilling(workspace: string, confirm: string, note: string, by: string): Promise<Result<BillingReset>>;
527529 /** The workspace's Stripe billing page, to send to the customer. Logged. */
528530 billingLink(workspace: string, by: string): Promise<Result<BillingLink>>;
529531 /** Where billing stands with Stripe; with `setup`, registers the webhook first. */
11461148 appliedAt: string | null;
11471149 };
11481150
1151+/** What `resetBilling` removed. */
1152+export type BillingReset = { workspace: string; rows: number };
1153+
11491154 export type WorkspaceCost = { workspace: string; costMicros: number; revenueMicros: number; givenMicros?: number; internal: boolean };
11501155
11511156 export type CostLineSummary = {
+1−0
437437 createEnterprise: (name, workspaces, by) => call("admin_create_enterprise", { name, workspaces, by }),
438438 attach: (workspace, account, by) => call("admin_attach", { workspace, account, by }),
439439 credit: (workspace, amountMicros, note, by) => call("admin_credit", { workspace, amount_micros: amountMicros, note, by }),
440+ resetBilling: (workspace, confirm, note, by) => call("admin_reset_billing", { workspace, confirm, note, by }),
440441 billingLink: (workspace, by) => call("admin_billing_link", { workspace, by }),
441442 stripe: (fix = false, by) => call("admin_stripe", { fix, by: by ?? null }),
442443 enterpriseBilling: (id, email, by) => call("admin_enterprise_billing", { id, email, by }),
+2−0
3737 mod keeper;
3838 mod limits;
3939 mod rename;
40+mod reset;
4041 mod retention;
4142 mod stripe;
4243 mod stripe_sync;
11901191 "admin_create_enterprise" => reply(&billing.admin_create_enterprise(args(body)?).await?),
11911192 "admin_attach" => reply(&billing.admin_attach(args(body)?).await?),
11921193 "admin_credit" => reply(&billing.admin_credit(args(body)?).await?),
1194+ "admin_reset_billing" => reply(&billing.admin_reset_billing(args(body)?).await?),
11931195 "admin_set_allowances" => reply(&billing.admin_set_allowances(args(body)?).await?),
11941196 "entitlements" => reply(&billing.entitlements(args(body)?).await?),
11951197 "audit_retention" => reply(&billing.audit_retention(args(body)?).await?),
+141−0
1+//! A test workspace's billing, wiped: `admin_reset_billing`.
2+//!
3+//! While billing runs on Stripe's test key, staff can return a workspace
4+//! used for testing to how a new customer starts: no ledger, balance,
5+//! plan, limits, trial grant, invoices, holds, signals or cost rows. Its
6+//! workspace, members and repositories are not billing's and stay. Never
7+//! with a live Stripe key, never for a comped workspace, and never for one
8+//! an enterprise pays for. The reset itself is kept in the audit log, and
9+//! g1t's own counts of the workspace's git operations stay: they are what
10+//! Cloudflare's bill is compared with, not what the workspace owes.
11+
12+use g1t_contracts::billing::{AdminResetBillingArgs, BillingReset};
13+use g1t_contracts::{FailureCode, Outcome};
14+use serde::Deserialize;
15+use worker::Result;
16+use worker::wasm_bindgen::JsValue;
17+
18+use crate::Billing;
19+use crate::accounts::own_account;
20+
21+/// The statements, in order. Parameters: `?1` the workspace, `?2` its own
22+/// billing account (`ws_<slug>`).
23+pub(crate) const STATEMENTS: &[&str] = &[
24+ "DELETE FROM workspace_invoice_lines WHERE invoice_id IN (SELECT invoice_id FROM workspace_invoices WHERE workspace = ?1)",
25+ "DELETE FROM workspace_invoices WHERE workspace = ?1",
26+ "DELETE FROM ledger WHERE workspace = ?1",
27+ "DELETE FROM runs WHERE workspace = ?1",
28+ "DELETE FROM reservations WHERE workspace = ?1",
29+ "DELETE FROM checkouts WHERE workspace = ?1",
30+ "DELETE FROM accounts WHERE workspace = ?1",
31+ "DELETE FROM plan_payments WHERE workspace = ?1",
32+ "DELETE FROM subscriptions WHERE workspace = ?1",
33+ "DELETE FROM limits WHERE workspace = ?1",
34+ "DELETE FROM limit_requests WHERE workspace = ?1",
35+ "DELETE FROM trial_grants WHERE workspace = ?1",
36+ "DELETE FROM card_checks WHERE workspace = ?1",
37+ "DELETE FROM alerts_sent WHERE workspace = ?1",
38+ "DELETE FROM price_notices WHERE workspace = ?1",
39+ "DELETE FROM pending_usage WHERE workspace = ?1",
40+ "DELETE FROM pending_days WHERE workspace = ?1",
41+ "DELETE FROM month_closes WHERE workspace = ?1",
42+ "DELETE FROM storage_days WHERE workspace = ?1",
43+ "DELETE FROM package_storage_days WHERE workspace = ?1",
44+ "DELETE FROM sandbox_months WHERE workspace = ?1",
45+ "DELETE FROM token_usage WHERE workspace = ?1",
46+ "DELETE FROM spikes WHERE workspace = ?1",
47+ "DELETE FROM closed_workspaces WHERE workspace = ?1",
48+ "DELETE FROM sales_records WHERE workspace = ?1",
49+ "DELETE FROM sales_notes WHERE workspace = ?1",
50+ "DELETE FROM workspace_costs WHERE workspace = ?1",
51+ "DELETE FROM margin_alerts WHERE kind = 'workspace' AND subject = ?1",
52+ // Allowances drawn by the workspace, and its repositories' shares of
53+ // the open-source pool (`<slug>/<name>`, compared exactly).
54+ "DELETE FROM allowance_use WHERE scope = ?1 OR (kind = 'oss_repo' AND substr(scope, 1, length(?1) + 1) = ?1 || '/')",
55+ "DELETE FROM budget_alerts WHERE account = ?2",
56+ "DELETE FROM billing_accounts WHERE id = ?2",
57+];
58+
59+impl Billing {
60+ pub(crate) async fn admin_reset_billing(&self, a: AdminResetBillingArgs) -> Result<Outcome<BillingReset>> {
61+ let workspace = a.workspace.trim().to_lowercase();
62+ if workspace.is_empty() || a.by.trim().is_empty() {
63+ return Ok(Outcome::fail(FailureCode::Invalid, "A reset needs a workspace and who did it."));
64+ }
65+ if a.note.trim().len() < 5 {
66+ return Ok(Outcome::fail(FailureCode::Invalid, "Say why it is reset, for whoever looks next."));
67+ }
68+ if a.confirm.trim() != workspace {
69+ return Ok(Outcome::fail(FailureCode::Invalid, format!("Type the workspace's slug, {workspace}, exactly, to reset it.")));
70+ }
71+ if self.stripe.as_ref().is_some_and(|s| s.live()) {
72+ return Ok(Outcome::fail(FailureCode::Forbidden, "Billing takes real cards: a workspace's billing is never wiped."));
73+ }
74+ #[derive(Deserialize)]
75+ struct Found {
76+ comped: i64,
77+ enterprise: i64,
78+ }
79+ let found = self
80+ .db
81+ .prepare(format!(
82+ "SELECT CASE WHEN ?1 IN ({}) THEN 1 ELSE 0 END AS comped,
83+ (SELECT COUNT(*) FROM account_members WHERE workspace = ?1) AS enterprise",
84+ crate::sales::INTERNAL_SQL
85+ ))
86+ .bind(&[workspace.as_str().into()])?
87+ .first::<Found>(None)
88+ .await?;
89+ if let Some(found) = found {
90+ if found.comped > 0 {
91+ return Ok(Outcome::fail(FailureCode::Forbidden, format!("{workspace} is comped (g1t's own): its spend is a budget, kept.")));
92+ }
93+ if found.enterprise > 0 {
94+ return Ok(Outcome::fail(FailureCode::Forbidden, format!("An enterprise pays for {workspace}: move it off first.")));
95+ }
96+ }
97+ let account = own_account(&workspace);
98+ let mut batch = Vec::with_capacity(STATEMENTS.len());
99+ for sql in STATEMENTS {
100+ let values: Vec<JsValue> =
101+ [workspace.as_str(), account.as_str()][..crate::rename::parameters(sql)].iter().map(|v| (*v).into()).collect();
102+ batch.push(self.db.prepare(*sql).bind(&values)?);
103+ }
104+ let mut rows = 0usize;
105+ for result in self.db.batch(batch).await? {
106+ rows += result.meta()?.and_then(|m| m.changes).unwrap_or(0);
107+ }
108+ self.audit(&account, "reset", &format!("billing of {workspace} reset ({rows} rows): {}", a.note.trim()), &a.by).await?;
109+ Ok(Outcome::Ok(BillingReset { workspace, rows: rows as u32 }))
110+ }
111+}
112+
113+#[cfg(test)]
114+mod tests {
115+ use super::*;
116+
117+ #[test]
118+ fn every_table_with_a_workspace_is_wiped_or_kept_on_purpose() {
119+ let all = STATEMENTS.join("\n");
120+ // What is kept: the audit log, and g1t's own counts compared with
121+ // Cloudflare's bill.
122+ let kept = ["admin_actions", "own_counts"];
123+ for table in [
124+ "ledger", "runs", "checkouts", "workspace_invoices", "workspace_invoice_lines", "sales_notes", "accounts",
125+ "pending_usage", "pending_days", "limits", "subscriptions", "month_closes", "sales_records",
126+ "billing_accounts", "allowance_use", "trial_grants", "storage_days", "package_storage_days",
127+ "sandbox_months", "token_usage", "reservations", "spikes", "limit_requests", "plan_payments",
128+ "card_checks", "alerts_sent", "price_notices", "closed_workspaces", "workspace_costs",
129+ "margin_alerts", "budget_alerts",
130+ ] {
131+ assert!(!kept.contains(&table));
132+ assert!(all.contains(&format!("DELETE FROM {table} WHERE")), "{table}");
133+ }
134+ }
135+
136+ #[test]
137+ fn statements_name_at_most_the_workspace_and_its_account() {
138+ assert!(STATEMENTS.iter().all(|sql| crate::rename::parameters(sql) <= 2));
139+ assert_eq!(crate::rename::parameters(STATEMENTS.last().unwrap()), 2);
140+ }
141+}