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.
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.