pr_01m47d15m3e54sn21z27rpy5n9/services/billing/src/closing.rs
| 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 | } |