Skip to content

g1t/services/identity/src/paid.rs

146 lines6,340 bytesCodeBlame

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 person1//! 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}

This file's history is long; its oldest lines are credited to the oldest commit read.