g1t/services/billing/src/closing.rs
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.
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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 | ||
| 21 | use g1t_contracts::billing::{CloseWorkspaceArgs, TermsKind}; | |
| 22 | use g1t_contracts::time::rfc3339; | |
| 23 | use g1t_contracts::{FailureCode, Outcome, Role}; | |
| 24 | use g1t_kit::now_ms; | |
| 25 | use serde::Deserialize; | |
| 26 | use worker::Result; | |
| 27 | ||
| 28 | use crate::Billing; | |
| 29 | use crate::accounts::own_account; | |
| 30 | use 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. | |
| 35 | pub(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)] | |
| 41 | pub(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)] | |
| 58 | pub(crate) enum Settle { | |
| 59 | Nothing, | |
| 60 | /// Charge the card this much now. | |
| 61 | Charge(i64), | |
| 62 | } | |
| 63 | ||
| 64 | pub(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)] | |
| 106 | struct Count { | |
| 107 | n: Option<f64>, | |
| 108 | } | |
| 109 | ||
| 110 | #[derive(Deserialize)] | |
| 111 | struct Subscription { | |
| 112 | feature: String, | |
| 113 | subscription_id: String, | |
| 114 | } | |
| 115 | ||
| 116 | impl 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)] | |
| 257 | mod 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 | } |