| 1 | //! What a free workspace may not do, asked of billing (`free_workspaces`). |
| 2 | //! |
| 3 | //! - **One free workspace per person.** A person may own at most one |
| 4 | //! workspace on no paid plan. Paid is the g1t plan, an enterprise's terms |
| 5 | //! or a 100% discount (g1t's own workspaces). Making a second free one is |
| 6 | //! refused with the way forward: start the plan on the one they have, or |
| 7 | //! delete it. People who own several from before keep them, and cannot |
| 8 | //! make more until all but one are paid for. |
| 9 | //! - **No one added to a free workspace.** It cannot add members, send |
| 10 | //! invites, or invite outside collaborators to its repositories, and an |
| 11 | //! invite sent before cannot be accepted, until it starts the plan. Its |
| 12 | //! members stay. g1t's own agent (@g1t) is never a member for this. |
| 13 | //! |
| 14 | //! Without billing bound (a g1t that does not take payments) nothing is |
| 15 | //! free and nothing is refused. When billing cannot be asked, the change |
| 16 | //! is refused for now, never let through unchecked. |
| 17 | |
| 18 | use g1t_contracts::billing::FreeWorkspacesArgs; |
| 19 | use g1t_contracts::{FailureCode, Outcome, Role}; |
| 20 | use worker::Result; |
| 21 | |
| 22 | use crate::Identity; |
| 23 | |
| 24 | /// Why a person cannot make another free workspace: they own `free` |
| 25 | /// already. None when they own none. |
| 26 | pub(crate) fn second_free_refusal(free: &[String]) -> Option<String> { |
| 27 | let first = free.first()?; |
| 28 | let (owned, them) = if free.len() == 1 { |
| 29 | (format!("You already own a free workspace, {first}"), "it") |
| 30 | } else { |
| 31 | (format!("You already own {} free workspaces ({})", free.len(), free.join(", ")), "each of them") |
| 32 | }; |
| 33 | Some(format!( |
| 34 | "{owned}, and each person can own one workspace that is not on the g1t plan. A new workspace starts free: to make one, start the plan on {them} (/{first}/-/billing#plan), or delete a free workspace you no longer use (in its settings, /{first}/-/settings)." |
| 35 | )) |
| 36 | } |
| 37 | |
| 38 | /// Why no one can be added to the free workspace `slug`. |
| 39 | pub(crate) fn invite_refusal(slug: &str) -> String { |
| 40 | format!( |
| 41 | "{slug} is a free workspace, so it cannot add people. Start the plan to invite people: an owner can start it at /{slug}/-/billing#plan. Its members stay as they are." |
| 42 | ) |
| 43 | } |
| 44 | |
| 45 | /// Whether `username` is g1t's own agent, which is never counted as a |
| 46 | /// person added to a workspace. |
| 47 | pub(crate) fn is_g1t(username: &str) -> bool { |
| 48 | username.trim().eq_ignore_ascii_case(g1t_contracts::identity::AGENT_NAME) |
| 49 | } |
| 50 | |
| 51 | /// What to say when billing could not be asked. |
| 52 | const UNCHECKED: &str = "g1t could not check the workspace's plan just now. Nothing was changed; try again in a minute."; |
| 53 | |
| 54 | impl Identity { |
| 55 | /// The free ones of `workspaces`, as billing says. Empty without |
| 56 | /// billing bound. |
| 57 | async fn free_of(&self, workspaces: Vec<String>) -> Result<Vec<String>> { |
| 58 | if workspaces.is_empty() { |
| 59 | return Ok(vec![]); |
| 60 | } |
| 61 | let Ok(billing) = self.env.service("BILLING") else { |
| 62 | return Ok(vec![]); |
| 63 | }; |
| 64 | g1t_kit::call(&billing, "free_workspaces", &FreeWorkspacesArgs { workspaces }).await |
| 65 | } |
| 66 | |
| 67 | /// A refusal if the person already owns a free workspace, for |
| 68 | /// `create_workspace`. |
| 69 | pub(crate) async fn second_free_workspace<T>(&self, user_id: &str) -> Result<Option<Outcome<T>>> { |
| 70 | let owned: Vec<String> = self |
| 71 | .memberships(user_id) |
| 72 | .await? |
| 73 | .into_iter() |
| 74 | .filter(|m| m.role == Role::Owner) |
| 75 | .map(|m| m.slug) |
| 76 | .collect(); |
| 77 | Ok(match self.free_of(owned).await { |
| 78 | Ok(free) => second_free_refusal(&free).map(|why| Outcome::fail(FailureCode::PaymentRequired, why)), |
| 79 | Err(error) => { |
| 80 | worker::console_error!("free workspaces of {user_id} not checked: {error}"); |
| 81 | Some(Outcome::fail(FailureCode::Conflict, UNCHECKED)) |
| 82 | } |
| 83 | }) |
| 84 | } |
| 85 | |
| 86 | /// A refusal if `slug` is a free workspace, before anyone is added to |
| 87 | /// it: as a member, by an invite, or as an outside collaborator. |
| 88 | pub(crate) async fn free_workspace_refusal<T>(&self, slug: &str) -> Result<Option<Outcome<T>>> { |
| 89 | let slug = slug.trim().to_lowercase(); |
| 90 | Ok(match self.free_of(vec![slug.clone()]).await { |
| 91 | Ok(free) if free.contains(&slug) => Some(Outcome::fail(FailureCode::PaymentRequired, invite_refusal(&slug))), |
| 92 | Ok(_) => None, |
| 93 | Err(error) => { |
| 94 | worker::console_error!("the plan of {slug} not checked before adding someone: {error}"); |
| 95 | Some(Outcome::fail(FailureCode::Conflict, UNCHECKED)) |
| 96 | } |
| 97 | }) |
| 98 | } |
| 99 | |
| 100 | /// Whether `slug` is free, for paths that skip rather than refuse (a |
| 101 | /// sign-up whose invite names a workspace). Free when billing cannot |
| 102 | /// be asked: nobody joins unchecked. |
| 103 | pub(crate) async fn is_free_workspace(&self, slug: &str) -> bool { |
| 104 | let slug = slug.trim().to_lowercase(); |
| 105 | match self.free_of(vec![slug.clone()]).await { |
| 106 | Ok(free) => free.contains(&slug), |
| 107 | Err(error) => { |
| 108 | worker::console_error!("the plan of {slug} not checked: {error}"); |
| 109 | true |
| 110 | } |
| 111 | } |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | #[cfg(test)] |
| 116 | mod tests { |
| 117 | use super::*; |
| 118 | |
| 119 | #[test] |
| 120 | fn a_second_free_workspace_is_refused_with_the_way_forward() { |
| 121 | assert_eq!(second_free_refusal(&[]), None); |
| 122 | let one = second_free_refusal(&["acme".to_owned()]).unwrap(); |
| 123 | assert!(one.starts_with("You already own a free workspace, acme")); |
| 124 | assert!(one.contains("/acme/-/billing#plan")); |
| 125 | assert!(one.contains("delete")); |
| 126 | // Grandfathered: several from before are named, and still no more. |
| 127 | let several = second_free_refusal(&["acme".to_owned(), "side".to_owned()]).unwrap(); |
| 128 | assert!(several.starts_with("You already own 2 free workspaces (acme, side)")); |
| 129 | } |
| 130 | |
| 131 | #[test] |
| 132 | fn a_free_workspace_is_told_to_start_the_plan_to_invite() { |
| 133 | let why = invite_refusal("acme"); |
| 134 | assert!(why.contains("Start the plan to invite people")); |
| 135 | assert!(why.contains("/acme/-/billing#plan")); |
| 136 | assert!(why.contains("members stay")); |
| 137 | } |
| 138 | |
| 139 | #[test] |
| 140 | fn g1ts_agent_is_never_a_person_added() { |
| 141 | assert!(is_g1t("g1t")); |
| 142 | assert!(is_g1t(" G1T ")); |
| 143 | assert!(!is_g1t("ada")); |
| 144 | assert!(!is_g1t("g1t-fan")); |
| 145 | } |
| 146 | } |