pr_01m47d15m3e54sn21z27rpy5n9/services/billing/src/closing.rs

304 lines11,417 bytesCodeBlame
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 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
21use g1t_contracts::billing::{CloseWorkspaceArgs, TermsKind};
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::dollars;
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 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.",
82 dollars(facts.balance_micros)
83 ));
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 {}.",
88 dollars(facts.metering_micros),
89 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.",
97 dollars(owed)
98 ));
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()),
144 comped: account.terms.kind == TermsKind::Comped,
145 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.",
179 dollars(owed)
180 ),
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}