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
327 linesCodeBlameRaw
1//! 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 is still unpaid (open, or failed);
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
21use g1t_contracts::billing::CloseWorkspaceArgs;
22use 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;
30use crate::features::cents;
31
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 /// Invoices sent and not paid: open, or failed.
47 pub unpaid_invoices: u32,
48 /// Positive is credit, negative is owed.
49 pub balance_micros: i64,
50 pub has_card: bool,
51 /// Usage this month that is charged when the month closes.
52 pub metering_micros: i64,
53 /// The first day of next month, `YYYY-MM-DD`.
54 pub next_month: String,
55}
56
57/// What closing does about money.
58#[derive(Debug, PartialEq, Eq)]
59pub(crate) enum Settle {
60 Nothing,
61 /// Charge the card this much now.
62 Charge(i64),
63}
64
65pub(crate) fn decide(facts: &Facts) -> std::result::Result<Settle, String> {
66 let ws = &facts.workspace;
67 if let Some(enterprise) = &facts.enterprise {
68 return Err(format!(
69 "{ws} is billed through {enterprise}. Write to support@g1t.sh to have it moved off that account first."
70 ));
71 }
72 if facts.comped {
73 return Ok(Settle::Nothing);
74 }
75 if facts.unpaid_invoices > 0 {
76 return Err(format!(
77 "{ws} has an unpaid invoice. Pay it from the workspace's Billing page first."
78 ));
79 }
80 if facts.balance_micros > 0 {
81 return Err(format!(
82 "{ws} has {} of prepaid credit left, which would be lost. Spend it, or write to support@g1t.sh about a refund, first.",
83 cents(facts.balance_micros)
84 ));
85 }
86 if facts.metering_micros > 0 {
87 return Err(format!(
88 "{ws} has {} of usage this month (storage and git operations) that is charged when the month closes. You can delete it from {}.",
89 cents(facts.metering_micros),
90 facts.next_month
91 ));
92 }
93 let owed = -facts.balance_micros;
94 if owed > 0 {
95 if !facts.has_card {
96 return Err(format!(
97 "{ws} owes {}. Add a card or pay it from the workspace's Billing page first.",
98 cents(owed)
99 ));
100 }
101 return Ok(Settle::Charge(owed));
102 }
103 Ok(Settle::Nothing)
104}
105
106/// The workspace's invoices sent and not paid: open (sent, or being
107/// charged) or failed. Either is money already asked for that closing must
108/// not ask for again.
109pub(crate) const UNPAID_INVOICES_SQL: &str = "SELECT
110 (SELECT count(*) FROM workspace_invoices WHERE workspace = ?1 AND status IN ('open', 'failed')) +
111 (SELECT count(*) FROM invoice_attempts a WHERE workspace = ?1 AND NOT EXISTS
112 (SELECT 1 FROM workspace_invoices i WHERE i.invoice_id = a.invoice_id AND i.status IN ('paid', 'void'))) AS n";
113
114#[derive(Deserialize)]
115struct Count {
116 n: Option<f64>,
117}
118
119#[derive(Deserialize)]
120struct Subscription {
121 feature: String,
122 subscription_id: String,
123}
124
125impl Billing {
126 async fn closing_facts(&self, workspace: &str) -> Result<Facts> {
127 let account = self.account_of(workspace).await?;
128 let row = self.row(workspace).await?;
129 let failed = self
130 .db
131 .prepare(UNPAID_INVOICES_SQL)
132 .bind(&[workspace.into()])?
133 .first::<Count>(None)
134 .await?
135 .and_then(|c| c.n)
136 .unwrap_or(0.0) as u32;
137 let now = rfc3339(now_ms());
138 let month = crate::credits::month_of(&now);
139 let metering = self
140 .db
141 .prepare(format!(
142 "SELECT SUM({}) AS n FROM pending_usage
143 WHERE workspace = ? AND charged_at IS NULL AND month >= ?",
144 crate::storage::NET_CHARGE
145 ))
146 .bind(&[workspace.into(), month.as_str().into()])?
147 .first::<Count>(None)
148 .await?
149 .and_then(|c| c.n)
150 .unwrap_or(0.0) as i64;
151 Ok(Facts {
152 workspace: workspace.to_owned(),
153 enterprise: account.id.starts_with("ent_").then(|| account.name.clone()),
154 comped: account.terms.full_discount(),
155 unpaid_invoices: failed,
156 balance_micros: row.as_ref().map_or(0, |r| r.balance_micros),
157 has_card: row.as_ref().is_some_and(|r| r.customer_id.is_some()) && self.stripe.is_some(),
158 metering_micros: metering,
159 next_month: crate::credits::next_month_start(&month)[..10].to_owned(),
160 })
161 }
162
163 /// `close_workspace`: see `g1t_contracts::billing::CloseWorkspaceArgs`.
164 pub(crate) async fn close_workspace(&self, a: CloseWorkspaceArgs) -> Result<Outcome<bool>> {
165 let workspace = a.workspace.trim().to_lowercase();
166 if a.actor.role_in(&workspace) != Some(Role::Owner) {
167 return Ok(Outcome::fail(
168 FailureCode::Forbidden,
169 "Only an owner can close a workspace's billing.",
170 ));
171 }
172 let facts = self.closing_facts(&workspace).await?;
173 let settle = match decide(&facts) {
174 Ok(settle) => settle,
175 Err(reason) => return Ok(Outcome::fail(FailureCode::PaymentRequired, reason)),
176 };
177 if a.dry_run {
178 return Ok(Outcome::Ok(true));
179 }
180 if let Settle::Charge(owed) = settle {
181 let period = rfc3339(now_ms())[..10].to_owned();
182 let invoiced = match self.invoice_workspace(&workspace, "close", &period).await {
183 Ok(invoiced) => invoiced,
184 // Stripe did not finish: nothing is closed, and it can be tried again.
185 Err(error) => {
186 worker::console_error!("{workspace}: not invoiced on closing: {error}");
187 return Ok(Outcome::fail(
188 FailureCode::Conflict,
189 "The card processor did not finish the payment. Nothing was closed; try again in a minute.",
190 ));
191 }
192 };
193 match invoiced {
194 Ok(invoice) if invoice.status == "paid" => {}
195 Ok(_) => {
196 return Ok(Outcome::fail(
197 FailureCode::PaymentRequired,
198 format!(
199 "The card on file was declined for the {} {workspace} owes. Pay it from the workspace's Billing page first.",
200 cents(owed)
201 ),
202 ));
203 }
204 Err(why) => return Ok(Outcome::fail(FailureCode::PaymentRequired, why)),
205 }
206 }
207 self.end_plans(&workspace).await?;
208 let now = rfc3339(now_ms());
209 let account = own_account(&workspace);
210 self.db
211 .batch(vec![
212 self.db
213 .prepare(
214 "INSERT OR REPLACE INTO closed_workspaces (workspace, closed_by, closed_at, balance_micros)
215 VALUES (?, ?, ?, ?)",
216 )
217 .bind(&[
218 workspace.as_str().into(),
219 a.actor.username.as_str().into(),
220 now.as_str().into(),
221 (facts.balance_micros as f64).into(),
222 ])?,
223 // Terms set for the workspace end with it; the name, made
224 // again, starts on standard terms.
225 self.db
226 .prepare(
227 "UPDATE billing_accounts SET terms_kind = 'standard', terms_set_by = 'closed', terms_set_at = ?
228 WHERE id = ? AND terms_kind <> 'standard'",
229 )
230 .bind(&[now.as_str().into(), account.as_str().into()])?,
231 ])
232 .await?;
233 self.audit(
234 &account,
235 "close",
236 &format!("Closed: {workspace} was deleted by {}", a.actor.username),
237 &a.actor.username,
238 )
239 .await?;
240 Ok(Outcome::Ok(true))
241 }
242
243 /// Ends every plan the workspace has at Stripe now, rather than at the
244 /// end of the period: there is nothing left to use it for.
245 async fn end_plans(&self, workspace: &str) -> Result<()> {
246 let live = self
247 .db
248 .prepare(
249 "SELECT feature, subscription_id FROM subscriptions
250 WHERE workspace = ? AND status <> 'canceled'",
251 )
252 .bind(&[workspace.into()])?
253 .all()
254 .await?
255 .results::<Subscription>()?;
256 for plan in live {
257 if let Some(stripe) = &self.stripe {
258 match stripe.cancel_now(&plan.subscription_id).await {
259 Ok(_) => {}
260 Err(error) if crate::stripe::is_missing(&error) => {}
261 Err(error) => return Err(error),
262 }
263 }
264 self.db
265 .prepare(
266 "UPDATE subscriptions SET status = 'canceled', updated_at = ?
267 WHERE workspace = ? AND feature = ?",
268 )
269 .bind(&[rfc3339(now_ms()).into(), workspace.into(), plan.feature.as_str().into()])?
270 .run()
271 .await?;
272 }
273 Ok(())
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 fn facts() -> Facts {
282 Facts {
283 workspace: "acme".into(),
284 has_card: true,
285 next_month: "2026-11-01".into(),
286 ..Facts::default()
287 }
288 }
289
290 #[test]
291 fn a_transfer_moves_only_the_pool_share() {
292 for sql in TRANSFERRED {
293 assert_eq!(g1t_kit::transfer::parameters(sql), 2, "{sql}");
294 }
295 }
296
297 #[test]
298 fn a_settled_workspace_closes() {
299 assert_eq!(decide(&facts()), Ok(Settle::Nothing));
300 }
301
302 #[test]
303 fn what_is_owed_is_charged_now() {
304 assert_eq!(decide(&Facts { balance_micros: -2_500_000, ..facts() }), Ok(Settle::Charge(2_500_000)));
305 let no_card = decide(&Facts { balance_micros: -2_500_000, has_card: false, ..facts() }).unwrap_err();
306 assert!(no_card.contains("owes $2.50"), "{no_card}");
307 }
308
309 #[test]
310 fn credit_metering_and_unpaid_invoices_wait() {
311 assert!(decide(&Facts { balance_micros: 5_000_000, ..facts() }).unwrap_err().contains("prepaid credit"));
312 assert!(decide(&Facts { unpaid_invoices: 1, ..facts() }).unwrap_err().contains("unpaid invoice"));
313 // An invoice still open counts as unpaid, as a failed one does.
314 assert!(UNPAID_INVOICES_SQL.contains("status IN ('open', 'failed')"), "{UNPAID_INVOICES_SQL}");
315 let metering = decide(&Facts { metering_micros: 10_000, ..facts() }).unwrap_err();
316 assert!(metering.contains("2026-11-01"), "{metering}");
317 }
318
319 #[test]
320 fn comped_owes_nothing_and_enterprises_go_through_staff() {
321 assert_eq!(
322 decide(&Facts { comped: true, balance_micros: -9_000_000, metering_micros: 1, ..facts() }),
323 Ok(Settle::Nothing)
324 );
325 assert!(decide(&Facts { enterprise: Some("Initech".into()), ..facts() }).unwrap_err().contains("Initech"));
326 }
327}