Skip to content
324 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 count(*) AS n FROM workspace_invoices WHERE workspace = ? AND status IN ('open', 'failed')";
110
111#[derive(Deserialize)]
112struct Count {
113 n: Option<f64>,
114}
115
116#[derive(Deserialize)]
117struct Subscription {
118 feature: String,
119 subscription_id: String,
120}
121
122impl Billing {
123 async fn closing_facts(&self, workspace: &str) -> Result<Facts> {
124 let account = self.account_of(workspace).await?;
125 let row = self.row(workspace).await?;
126 let failed = self
127 .db
128 .prepare(UNPAID_INVOICES_SQL)
129 .bind(&[workspace.into()])?
130 .first::<Count>(None)
131 .await?
132 .and_then(|c| c.n)
133 .unwrap_or(0.0) as u32;
134 let now = rfc3339(now_ms());
135 let month = crate::credits::month_of(&now);
136 let metering = self
137 .db
138 .prepare(format!(
139 "SELECT SUM({}) AS n FROM pending_usage
140 WHERE workspace = ? AND charged_at IS NULL AND month >= ?",
141 crate::storage::NET_CHARGE
142 ))
143 .bind(&[workspace.into(), month.as_str().into()])?
144 .first::<Count>(None)
145 .await?
146 .and_then(|c| c.n)
147 .unwrap_or(0.0) as i64;
148 Ok(Facts {
149 workspace: workspace.to_owned(),
150 enterprise: account.id.starts_with("ent_").then(|| account.name.clone()),
151 comped: account.terms.full_discount(),
152 unpaid_invoices: failed,
153 balance_micros: row.as_ref().map_or(0, |r| r.balance_micros),
154 has_card: row.as_ref().is_some_and(|r| r.customer_id.is_some()) && self.stripe.is_some(),
155 metering_micros: metering,
156 next_month: crate::credits::next_month_start(&month)[..10].to_owned(),
157 })
158 }
159
160 /// `close_workspace`: see `g1t_contracts::billing::CloseWorkspaceArgs`.
161 pub(crate) async fn close_workspace(&self, a: CloseWorkspaceArgs) -> Result<Outcome<bool>> {
162 let workspace = a.workspace.trim().to_lowercase();
163 if a.actor.role_in(&workspace) != Some(Role::Owner) {
164 return Ok(Outcome::fail(
165 FailureCode::Forbidden,
166 "Only an owner can close a workspace's billing.",
167 ));
168 }
169 let facts = self.closing_facts(&workspace).await?;
170 let settle = match decide(&facts) {
171 Ok(settle) => settle,
172 Err(reason) => return Ok(Outcome::fail(FailureCode::PaymentRequired, reason)),
173 };
174 if a.dry_run {
175 return Ok(Outcome::Ok(true));
176 }
177 if let Settle::Charge(owed) = settle {
178 let period = rfc3339(now_ms())[..10].to_owned();
179 let invoiced = match self.invoice_workspace(&workspace, "close", &period).await {
180 Ok(invoiced) => invoiced,
181 // Stripe did not finish: nothing is closed, and it can be tried again.
182 Err(error) => {
183 worker::console_error!("{workspace}: not invoiced on closing: {error}");
184 return Ok(Outcome::fail(
185 FailureCode::Conflict,
186 "The card processor did not finish the payment. Nothing was closed; try again in a minute.",
187 ));
188 }
189 };
190 match invoiced {
191 Ok(invoice) if invoice.status == "paid" => {}
192 Ok(_) => {
193 return Ok(Outcome::fail(
194 FailureCode::PaymentRequired,
195 format!(
196 "The card on file was declined for the {} {workspace} owes. Pay it from the workspace's Billing page first.",
197 cents(owed)
198 ),
199 ));
200 }
201 Err(why) => return Ok(Outcome::fail(FailureCode::PaymentRequired, why)),
202 }
203 }
204 self.end_plans(&workspace).await?;
205 let now = rfc3339(now_ms());
206 let account = own_account(&workspace);
207 self.db
208 .batch(vec![
209 self.db
210 .prepare(
211 "INSERT OR REPLACE INTO closed_workspaces (workspace, closed_by, closed_at, balance_micros)
212 VALUES (?, ?, ?, ?)",
213 )
214 .bind(&[
215 workspace.as_str().into(),
216 a.actor.username.as_str().into(),
217 now.as_str().into(),
218 (facts.balance_micros as f64).into(),
219 ])?,
220 // Terms set for the workspace end with it; the name, made
221 // again, starts on standard terms.
222 self.db
223 .prepare(
224 "UPDATE billing_accounts SET terms_kind = 'standard', terms_set_by = 'closed', terms_set_at = ?
225 WHERE id = ? AND terms_kind <> 'standard'",
226 )
227 .bind(&[now.as_str().into(), account.as_str().into()])?,
228 ])
229 .await?;
230 self.audit(
231 &account,
232 "close",
233 &format!("Closed: {workspace} was deleted by {}", a.actor.username),
234 &a.actor.username,
235 )
236 .await?;
237 Ok(Outcome::Ok(true))
238 }
239
240 /// Ends every plan the workspace has at Stripe now, rather than at the
241 /// end of the period: there is nothing left to use it for.
242 async fn end_plans(&self, workspace: &str) -> Result<()> {
243 let live = self
244 .db
245 .prepare(
246 "SELECT feature, subscription_id FROM subscriptions
247 WHERE workspace = ? AND status <> 'canceled'",
248 )
249 .bind(&[workspace.into()])?
250 .all()
251 .await?
252 .results::<Subscription>()?;
253 for plan in live {
254 if let Some(stripe) = &self.stripe {
255 match stripe.cancel_now(&plan.subscription_id).await {
256 Ok(_) => {}
257 Err(error) if crate::stripe::is_missing(&error) => {}
258 Err(error) => return Err(error),
259 }
260 }
261 self.db
262 .prepare(
263 "UPDATE subscriptions SET status = 'canceled', updated_at = ?
264 WHERE workspace = ? AND feature = ?",
265 )
266 .bind(&[rfc3339(now_ms()).into(), workspace.into(), plan.feature.as_str().into()])?
267 .run()
268 .await?;
269 }
270 Ok(())
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 fn facts() -> Facts {
279 Facts {
280 workspace: "acme".into(),
281 has_card: true,
282 next_month: "2026-11-01".into(),
283 ..Facts::default()
284 }
285 }
286
287 #[test]
288 fn a_transfer_moves_only_the_pool_share() {
289 for sql in TRANSFERRED {
290 assert_eq!(g1t_kit::transfer::parameters(sql), 2, "{sql}");
291 }
292 }
293
294 #[test]
295 fn a_settled_workspace_closes() {
296 assert_eq!(decide(&facts()), Ok(Settle::Nothing));
297 }
298
299 #[test]
300 fn what_is_owed_is_charged_now() {
301 assert_eq!(decide(&Facts { balance_micros: -2_500_000, ..facts() }), Ok(Settle::Charge(2_500_000)));
302 let no_card = decide(&Facts { balance_micros: -2_500_000, has_card: false, ..facts() }).unwrap_err();
303 assert!(no_card.contains("owes $2.50"), "{no_card}");
304 }
305
306 #[test]
307 fn credit_metering_and_unpaid_invoices_wait() {
308 assert!(decide(&Facts { balance_micros: 5_000_000, ..facts() }).unwrap_err().contains("prepaid credit"));
309 assert!(decide(&Facts { unpaid_invoices: 1, ..facts() }).unwrap_err().contains("unpaid invoice"));
310 // An invoice still open counts as unpaid, as a failed one does.
311 assert!(UNPAID_INVOICES_SQL.contains("status IN ('open', 'failed')"), "{UNPAID_INVOICES_SQL}");
312 let metering = decide(&Facts { metering_micros: 10_000, ..facts() }).unwrap_err();
313 assert!(metering.contains("2026-11-01"), "{metering}");
314 }
315
316 #[test]
317 fn comped_owes_nothing_and_enterprises_go_through_staff() {
318 assert_eq!(
319 decide(&Facts { comped: true, balance_micros: -9_000_000, metering_micros: 1, ..facts() }),
320 Ok(Settle::Nothing)
321 );
322 assert!(decide(&Facts { enterprise: Some("Initech".into()), ..facts() }).unwrap_err().contains("Initech"));
323 }
324}