Skip to content

g1t/services/identity/src/paid.rs

146 lines6,340 bytesCodeBlame
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 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)]
116mod 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}