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
11 files+250−10/11 viewed
| 3 | 3 | * confirmations, terms, plan, pools and caps, credit, bank transfers, the | |
| 4 | 4 | * Stripe billing link, the ledger and the audit log. Plain forms; sudo ships no JavaScript. | |
| 5 | 5 | */ | |
| 6 | − | import { CreditCard, Gift, Landmark, ScrollText, UserRound } from "lucide-react"; | |
| 6 | + | import { CreditCard, Gift, Landmark, RotateCcw, ScrollText, UserRound } from "lucide-react"; | |
| 7 | 7 | import type { ReactNode } from "react"; | |
| 8 | 8 | import { Link } from "react-router"; | |
| 9 | 9 | ||
| 512 | 512 | ); | |
| 513 | 513 | } | |
| 514 | 514 | ||
| 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 | + | ||
| 515 | 557 | // --- Stripe billing link ----------------------------------------------------- | |
| 516 | 558 | ||
| 517 | 559 | /** |
| 121 | 121 | return back("credit"); | |
| 122 | 122 | } | |
| 123 | 123 | ||
| 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 | + | ||
| 124 | 139 | if (intent === "payment") { | |
| 125 | 140 | // A bank transfer that reached g1t outside Stripe's page. | |
| 126 | 141 | const values = fields(form, "amount", "reference", "note", "confirmation"); |
| 97 | 97 | attach: "Workspace added", | |
| 98 | 98 | detach: "Workspace removed", | |
| 99 | 99 | credit: "Credit issued", | |
| 100 | + | reset: "Billing reset (testing)", | |
| 100 | 101 | billing_link: "Billing link made", | |
| 101 | 102 | billing_email: "Invoice email set", | |
| 102 | 103 | invoice: "Invoice sent", |
| 28 | 28 | attach: "Workspace moved onto the enterprise.", | |
| 29 | 29 | detach: "Workspace moved off the enterprise. It pays for itself again.", | |
| 30 | 30 | credit: "Credit issued.", | |
| 31 | + | reset: "Billing reset. The workspace starts again as a new customer; run the costs analysis to redo the margin figures.", | |
| 31 | 32 | created: "Enterprise created.", | |
| 32 | 33 | "billing-email": "Saved where the enterprise's invoices go.", | |
| 33 | 34 | sales: "Sales record saved.", |
| 14 | 14 | LedgerSection, | |
| 15 | 15 | Owners, | |
| 16 | 16 | PaymentForm, | |
| 17 | + | ResetBillingForm, | |
| 17 | 18 | ReviewPanel, | |
| 18 | 19 | TermsForm, | |
| 19 | 20 | } from "~/components/billing"; | |
| 355 | 356 | )} | |
| 356 | 357 | <PaymentForm workspace={slug} pathname={pathname} error={error("payment")} /> | |
| 357 | 358 | <CreditForm workspaces={[slug]} pathname={pathname} error={error("credit")} /> | |
| 359 | + | {!billedTo && terms.kind !== "comped" && <ResetBillingForm workspace={slug} pathname={pathname} error={error("reset")} />} | |
| 358 | 360 | <div id="audit" className="scroll-mt-20"> | |
| 359 | 361 | <AuditSection | |
| 360 | 362 | audit={audit} |
| 2154 | 2154 | pub by: String, | |
| 2155 | 2155 | } | |
| 2156 | 2156 | ||
| 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 | + | ||
| 2157 | 2177 | /// One change made in sudo. | |
| 2158 | 2178 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 2159 | 2179 | #[serde(rename_all = "camelCase")] |
| 346 | 346 | - **Change a default**: edit the variable in `services/billing/wrangler.jsonc` | |
| 347 | 347 | and deploy g1t-billing. | |
| 348 | 348 | ||
| 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 | + | ||
| 349 | 368 | ## Stripe | |
| 350 | 369 | ||
| 351 | 370 | Billing keeps what it needs from Stripe so reads never wait on it, and |
| 524 | 524 | createEnterprise(name: string, workspaces: string[], by: string): Promise<Result<PayingAccount>>; | |
| 525 | 525 | attach(workspace: string, account: string | null, by: string): Promise<Result<PayingAccount>>; | |
| 526 | 526 | 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>>; | |
| 527 | 529 | /** The workspace's Stripe billing page, to send to the customer. Logged. */ | |
| 528 | 530 | billingLink(workspace: string, by: string): Promise<Result<BillingLink>>; | |
| 529 | 531 | /** Where billing stands with Stripe; with `setup`, registers the webhook first. */ | |
| 1146 | 1148 | appliedAt: string | null; | |
| 1147 | 1149 | }; | |
| 1148 | 1150 | ||
| 1151 | + | /** What `resetBilling` removed. */ | |
| 1152 | + | export type BillingReset = { workspace: string; rows: number }; | |
| 1153 | + | ||
| 1149 | 1154 | export type WorkspaceCost = { workspace: string; costMicros: number; revenueMicros: number; givenMicros?: number; internal: boolean }; | |
| 1150 | 1155 | ||
| 1151 | 1156 | export type CostLineSummary = { |
| 437 | 437 | createEnterprise: (name, workspaces, by) => call("admin_create_enterprise", { name, workspaces, by }), | |
| 438 | 438 | attach: (workspace, account, by) => call("admin_attach", { workspace, account, by }), | |
| 439 | 439 | 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 }), | |
| 440 | 441 | billingLink: (workspace, by) => call("admin_billing_link", { workspace, by }), | |
| 441 | 442 | stripe: (fix = false, by) => call("admin_stripe", { fix, by: by ?? null }), | |
| 442 | 443 | enterpriseBilling: (id, email, by) => call("admin_enterprise_billing", { id, email, by }), |
| 37 | 37 | mod keeper; | |
| 38 | 38 | mod limits; | |
| 39 | 39 | mod rename; | |
| 40 | + | mod reset; | |
| 40 | 41 | mod retention; | |
| 41 | 42 | mod stripe; | |
| 42 | 43 | mod stripe_sync; | |
| 1190 | 1191 | "admin_create_enterprise" => reply(&billing.admin_create_enterprise(args(body)?).await?), | |
| 1191 | 1192 | "admin_attach" => reply(&billing.admin_attach(args(body)?).await?), | |
| 1192 | 1193 | "admin_credit" => reply(&billing.admin_credit(args(body)?).await?), | |
| 1194 | + | "admin_reset_billing" => reply(&billing.admin_reset_billing(args(body)?).await?), | |
| 1193 | 1195 | "admin_set_allowances" => reply(&billing.admin_set_allowances(args(body)?).await?), | |
| 1194 | 1196 | "entitlements" => reply(&billing.entitlements(args(body)?).await?), | |
| 1195 | 1197 | "audit_retention" => reply(&billing.audit_retention(args(body)?).await?), |
| 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 | + | } |