Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

152 linesCodeBlameRaw
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
18use g1t_contracts::billing::FreeWorkspacesArgs;
19use g1t_contracts::{FailureCode, Outcome, Role};
20use worker::Result;
21
22use crate::Identity;
23
24/// Why a person cannot make another free workspace: they own `free`
25/// already. None when they own none.
26pub(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`.
39pub(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.
47pub(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.
52const UNCHECKED: &str = "g1t could not check the workspace's plan just now. Nothing was changed; try again in a minute.";
53
54impl 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 self.plan_refusal(slug, invite_refusal(&slug.trim().to_lowercase())).await
90 }
91
92 /// A refusal saying `why` if `slug` is a free workspace, for what
93 /// comes with any paid plan (single sign-on, sso.rs).
94 pub(crate) async fn plan_refusal<T>(&self, slug: &str, why: String) -> Result<Option<Outcome<T>>> {
95 let slug = slug.trim().to_lowercase();
96 Ok(match self.free_of(vec![slug.clone()]).await {
97 Ok(free) if free.contains(&slug) => Some(Outcome::fail(FailureCode::PaymentRequired, why)),
98 Ok(_) => None,
99 Err(error) => {
100 worker::console_error!("the plan of {slug} not checked: {error}");
101 Some(Outcome::fail(FailureCode::Conflict, UNCHECKED))
102 }
103 })
104 }
105
106 /// Whether `slug` is free, for paths that skip rather than refuse (a
107 /// sign-up whose invite names a workspace). Free when billing cannot
108 /// be asked: nobody joins unchecked.
109 pub(crate) async fn is_free_workspace(&self, slug: &str) -> bool {
110 let slug = slug.trim().to_lowercase();
111 match self.free_of(vec![slug.clone()]).await {
112 Ok(free) => free.contains(&slug),
113 Err(error) => {
114 worker::console_error!("the plan of {slug} not checked: {error}");
115 true
116 }
117 }
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 #[test]
126 fn a_second_free_workspace_is_refused_with_the_way_forward() {
127 assert_eq!(second_free_refusal(&[]), None);
128 let one = second_free_refusal(&["acme".to_owned()]).unwrap();
129 assert!(one.starts_with("You already own a free workspace, acme"));
130 assert!(one.contains("/acme/-/billing#plan"));
131 assert!(one.contains("delete"));
132 // Grandfathered: several from before are named, and still no more.
133 let several = second_free_refusal(&["acme".to_owned(), "side".to_owned()]).unwrap();
134 assert!(several.starts_with("You already own 2 free workspaces (acme, side)"));
135 }
136
137 #[test]
138 fn a_free_workspace_is_told_to_start_the_plan_to_invite() {
139 let why = invite_refusal("acme");
140 assert!(why.contains("Start the plan to invite people"));
141 assert!(why.contains("/acme/-/billing#plan"));
142 assert!(why.contains("members stay"));
143 }
144
145 #[test]
146 fn g1ts_agent_is_never_a_person_added() {
147 assert!(is_g1t("g1t"));
148 assert!(is_g1t(" G1T "));
149 assert!(!is_g1t("ada"));
150 assert!(!is_g1t("g1t-fan"));
151 }
152}