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
304 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1//! Closing a workspace's billing before the workspace is deleted.
2//!
3//! Identity asks first (`dry_run`), to show the owner what stands in the
4//! way, and then for real, just before it deletes anything. A workspace can
5//! be closed when nothing is left between it and g1t:
6//!
7//! - no invoice of its failed and is still unpaid;
8//! - no prepaid credit is left (it would be lost);
9//! - what it owes can be charged now, to its card, with no minimum;
10//! - no usage this month is still being metered (storage and git
11//! operations are charged when the month closes).
12//!
13//! A comped workspace owes nothing, so only its plan is ended. A workspace
14//! an enterprise pays for is closed by g1t staff, who detach it first.
15//!
16//! Closing ends the plan at Stripe at once, puts the workspace's own
17//! account back on standard terms (so the name, if made again, starts
18//! like any other), and records it. The ledger, invoices and statements
19//! stay, under the slug, for accounting.
20
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging21use g1t_contracts::billing::CloseWorkspaceArgs;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas22use g1t_contracts::time::rfc3339;
23use g1t_contracts::{FailureCode, Outcome, Role};
24use g1t_kit::now_ms;
25use serde::Deserialize;
26use worker::Result;
27
28use crate::Billing;
29use crate::accounts::own_account;
Money in billing's messages reads to the cent, and the deploy reads migrations again after a failure30use crate::features::cents;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas31
32/// `repo.transferred` (see `g1t_kit::transfer`): `?1` the repository's
33/// path now, `?2` a path it had. Ledger rows, runs and holds keep the path
34/// they were charged under.
35pub(crate) const TRANSFERRED: &[&str] = &[
36 "UPDATE OR IGNORE allowance_use SET scope = ?1 WHERE kind = 'oss_repo' AND scope = ?2",
37];
38
39/// Everything that decides whether a workspace's billing can be closed.
40#[derive(Debug, Default)]
41pub(crate) struct Facts {
42 pub workspace: String,
43 /// The enterprise that pays for it, by name.
44 pub enterprise: Option<String>,
45 pub comped: bool,
46 pub failed_invoices: u32,
47 /// Positive is credit, negative is owed.
48 pub balance_micros: i64,
49 pub has_card: bool,
50 /// Usage this month that is charged when the month closes.
51 pub metering_micros: i64,
52 /// The first day of next month, `YYYY-MM-DD`.
53 pub next_month: String,
54}
55
56/// What closing does about money.
57#[derive(Debug, PartialEq, Eq)]
58pub(crate) enum Settle {
59 Nothing,
60 /// Charge the card this much now.
61 Charge(i64),
62}
63
64pub(crate) fn decide(facts: &Facts) -> std::result::Result<Settle, String> {
65 let ws = &facts.workspace;
66 if let Some(enterprise) = &facts.enterprise {
67 return Err(format!(
68 "{ws} is billed through {enterprise}. Write to support@g1t.sh to have it moved off that account first."
69 ));
70 }
71 if facts.comped {
72 return Ok(Settle::Nothing);
73 }
74 if facts.failed_invoices > 0 {
75 return Err(format!(
76 "{ws} has an unpaid invoice. Pay it from the workspace's Billing page first."
77 ));
78 }
79 if facts.balance_micros > 0 {
80 return Err(format!(
81 "{ws} has {} of prepaid credit left, which would be lost. Spend it, or write to support@g1t.sh about a refund, first.",
Money in billing's messages reads to the cent, and the deploy reads migrations again after a failure82 cents(facts.balance_micros)
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas83 ));
84 }
85 if facts.metering_micros > 0 {
86 return Err(format!(
87 "{ws} has {} of usage this month (storage and git operations) that is charged when the month closes. You can delete it from {}.",
Money in billing's messages reads to the cent, and the deploy reads migrations again after a failure88 cents(facts.metering_micros),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas89 facts.next_month
90 ));
91 }
92 let owed = -facts.balance_micros;
93 if owed > 0 {
94 if !facts.has_card {
95 return Err(format!(
96 "{ws} owes {}. Add a card or pay it from the workspace's Billing page first.",
Money in billing's messages reads to the cent, and the deploy reads migrations again after a failure97 cents(owed)
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas98 ));
99 }
100 return Ok(Settle::Charge(owed));
101 }
102 Ok(Settle::Nothing)
103}
104
105#[derive(Deserialize)]
106struct Count {
107 n: Option<f64>,
108}
109
110#[derive(Deserialize)]
111struct Subscription {
112 feature: String,
113 subscription_id: String,
114}
115
116impl Billing {
117 async fn closing_facts(&self, workspace: &str) -> Result<Facts> {
118 let account = self.account_of(workspace).await?;
119 let row = self.row(workspace).await?;
120 let failed = self
121 .db
122 .prepare("SELECT count(*) AS n FROM workspace_invoices WHERE workspace = ? AND status = 'failed'")
123 .bind(&[workspace.into()])?
124 .first::<Count>(None)
125 .await?
126 .and_then(|c| c.n)
127 .unwrap_or(0.0) as u32;
128 let now = rfc3339(now_ms());
129 let month = crate::credits::month_of(&now);
130 let metering = self
131 .db
132 .prepare(
133 "SELECT SUM(charge_micros) AS n FROM pending_usage
134 WHERE workspace = ? AND charged_at IS NULL AND month >= ?",
135 )
136 .bind(&[workspace.into(), month.as_str().into()])?
137 .first::<Count>(None)
138 .await?
139 .and_then(|c| c.n)
140 .unwrap_or(0.0) as i64;
141 Ok(Facts {
142 workspace: workspace.to_owned(),
143 enterprise: account.id.starts_with("ent_").then(|| account.name.clone()),
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging144 comped: account.terms.full_discount(),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas145 failed_invoices: failed,
146 balance_micros: row.as_ref().map_or(0, |r| r.balance_micros),
147 has_card: row.as_ref().is_some_and(|r| r.customer_id.is_some()) && self.stripe.is_some(),
148 metering_micros: metering,
149 next_month: crate::credits::next_month_start(&month)[..10].to_owned(),
150 })
151 }
152
153 /// `close_workspace`: see `g1t_contracts::billing::CloseWorkspaceArgs`.
154 pub(crate) async fn close_workspace(&self, a: CloseWorkspaceArgs) -> Result<Outcome<bool>> {
155 let workspace = a.workspace.trim().to_lowercase();
156 if a.actor.role_in(&workspace) != Some(Role::Owner) {
157 return Ok(Outcome::fail(
158 FailureCode::Forbidden,
159 "Only an owner can close a workspace's billing.",
160 ));
161 }
162 let facts = self.closing_facts(&workspace).await?;
163 let settle = match decide(&facts) {
164 Ok(settle) => settle,
165 Err(reason) => return Ok(Outcome::fail(FailureCode::PaymentRequired, reason)),
166 };
167 if a.dry_run {
168 return Ok(Outcome::Ok(true));
169 }
170 if let Settle::Charge(owed) = settle {
171 let period = rfc3339(now_ms())[..10].to_owned();
172 match self.invoice_workspace(&workspace, "close", &period).await? {
173 Ok(invoice) if invoice.status == "paid" => {}
174 Ok(_) => {
175 return Ok(Outcome::fail(
176 FailureCode::PaymentRequired,
177 format!(
178 "The card on file was declined for the {} {workspace} owes. Pay it from the workspace's Billing page first.",
Money in billing's messages reads to the cent, and the deploy reads migrations again after a failure179 cents(owed)
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas180 ),
181 ));
182 }
183 Err(why) => return Ok(Outcome::fail(FailureCode::PaymentRequired, why)),
184 }
185 }
186 self.end_plans(&workspace).await?;
187 let now = rfc3339(now_ms());
188 let account = own_account(&workspace);
189 self.db
190 .batch(vec![
191 self.db
192 .prepare(
193 "INSERT OR REPLACE INTO closed_workspaces (workspace, closed_by, closed_at, balance_micros)
194 VALUES (?, ?, ?, ?)",
195 )
196 .bind(&[
197 workspace.as_str().into(),
198 a.actor.username.as_str().into(),
199 now.as_str().into(),
200 (facts.balance_micros as f64).into(),
201 ])?,
202 // Terms set for the workspace end with it; the name, made
203 // again, starts on standard terms.
204 self.db
205 .prepare(
206 "UPDATE billing_accounts SET terms_kind = 'standard', terms_set_by = 'closed', terms_set_at = ?
207 WHERE id = ? AND terms_kind <> 'standard'",
208 )
209 .bind(&[now.as_str().into(), account.as_str().into()])?,
210 ])
211 .await?;
212 self.audit(
213 &account,
214 "close",
215 &format!("Closed: {workspace} was deleted by {}", a.actor.username),
216 &a.actor.username,
217 )
218 .await?;
219 Ok(Outcome::Ok(true))
220 }
221
222 /// Ends every plan the workspace has at Stripe now, rather than at the
223 /// end of the period: there is nothing left to use it for.
224 async fn end_plans(&self, workspace: &str) -> Result<()> {
225 let live = self
226 .db
227 .prepare(
228 "SELECT feature, subscription_id FROM subscriptions
229 WHERE workspace = ? AND status <> 'canceled'",
230 )
231 .bind(&[workspace.into()])?
232 .all()
233 .await?
234 .results::<Subscription>()?;
235 for plan in live {
236 if let Some(stripe) = &self.stripe {
237 match stripe.cancel_now(&plan.subscription_id).await {
238 Ok(_) => {}
239 Err(error) if crate::stripe::is_missing(&error) => {}
240 Err(error) => return Err(error),
241 }
242 }
243 self.db
244 .prepare(
245 "UPDATE subscriptions SET status = 'canceled', updated_at = ?
246 WHERE workspace = ? AND feature = ?",
247 )
248 .bind(&[rfc3339(now_ms()).into(), workspace.into(), plan.feature.as_str().into()])?
249 .run()
250 .await?;
251 }
252 Ok(())
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 fn facts() -> Facts {
261 Facts {
262 workspace: "acme".into(),
263 has_card: true,
264 next_month: "2026-11-01".into(),
265 ..Facts::default()
266 }
267 }
268
269 #[test]
270 fn a_transfer_moves_only_the_pool_share() {
271 for sql in TRANSFERRED {
272 assert_eq!(g1t_kit::transfer::parameters(sql), 2, "{sql}");
273 }
274 }
275
276 #[test]
277 fn a_settled_workspace_closes() {
278 assert_eq!(decide(&facts()), Ok(Settle::Nothing));
279 }
280
281 #[test]
282 fn what_is_owed_is_charged_now() {
283 assert_eq!(decide(&Facts { balance_micros: -2_500_000, ..facts() }), Ok(Settle::Charge(2_500_000)));
284 let no_card = decide(&Facts { balance_micros: -2_500_000, has_card: false, ..facts() }).unwrap_err();
285 assert!(no_card.contains("owes $2.50"), "{no_card}");
286 }
287
288 #[test]
289 fn credit_metering_and_failed_invoices_wait() {
290 assert!(decide(&Facts { balance_micros: 5_000_000, ..facts() }).unwrap_err().contains("prepaid credit"));
291 assert!(decide(&Facts { failed_invoices: 1, ..facts() }).unwrap_err().contains("unpaid invoice"));
292 let metering = decide(&Facts { metering_micros: 10_000, ..facts() }).unwrap_err();
293 assert!(metering.contains("2026-11-01"), "{metering}");
294 }
295
296 #[test]
297 fn comped_owes_nothing_and_enterprises_go_through_staff() {
298 assert_eq!(
299 decide(&Facts { comped: true, balance_micros: -9_000_000, metering_micros: 1, ..facts() }),
300 Ok(Settle::Nothing)
301 );
302 assert!(decide(&Facts { enterprise: Some("Initech".into()), ..facts() }).unwrap_err().contains("Initech"));
303 }
304}

This file's history is long; its oldest lines are credited to the oldest commit read.