flagon-io/g1t

public

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

g1t/services/billing/src/stripe.rs

340 lines11,272 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.

Paid features: a workspace turns on Deployments with a monthly plan1//! The card processor, behind the calls billing needs: start a payment
2//! page, ask whether a payment was made, and read or end a monthly plan. Stripe speaks form-encoded
Agents as a team: lifecycle, merge queue, billing and a new shell3//! requests and JSON answers.
4
5use serde::Deserialize;
6use worker::{Error, Fetch, Headers, Method, Request, RequestInit, Result};
7
8const API: &str = "https://api.stripe.com/v1";
9
10pub struct Stripe {
11 key: String,
12}
13
14/// A payment page, and the payment made through it.
15#[derive(Deserialize)]
16pub struct Session {
17 pub id: String,
18 /// Where to send the person. Absent once the page has been used.
19 pub url: Option<String>,
20 /// `paid` once the money has been taken.
21 pub payment_status: String,
22 /// What was paid, in cents.
23 pub amount_total: Option<u32>,
24 pub customer: Option<String>,
Paid features: a workspace turns on Deployments with a monthly plan25 /// For a plan's page: the subscription it started.
26 #[serde(default)]
27 pub subscription: Option<String>,
Agents as a team: lifecycle, merge queue, billing and a new shell28}
29
Paid features: a workspace turns on Deployments with a monthly plan30/// A monthly plan.
31#[derive(Deserialize)]
32pub struct StripeSubscription {
33 pub id: String,
34 /// `active`, `trialing`, `past_due`, `unpaid`, `canceled`, `incomplete`…
35 pub status: String,
36 #[serde(default)]
37 pub cancel_at_period_end: bool,
38 /// Unix seconds. Older API versions carry it here…
39 #[serde(default)]
40 pub current_period_end: Option<i64>,
41 /// …newer ones on each item.
42 #[serde(default)]
43 pub items: Option<Items>,
44}
45
46#[derive(Deserialize)]
47pub struct Items {
48 pub data: Vec<Item>,
49}
50
51#[derive(Deserialize)]
52pub struct Item {
53 #[serde(default)]
54 pub current_period_end: Option<i64>,
55}
56
57impl StripeSubscription {
58 /// When the period paid for ends, in Unix seconds.
59 pub fn period_end(&self) -> Option<i64> {
60 self.current_period_end.or_else(|| {
61 self.items
62 .as_ref()
63 .and_then(|items| items.data.iter().filter_map(|item| item.current_period_end).max())
64 })
65 }
66}
67
Agents as a team: lifecycle, merge queue, billing and a new shell68/// Percent-encodes a form value.
69fn encode(value: &str) -> String {
70 let mut encoded = String::with_capacity(value.len());
71 for byte in value.bytes() {
72 match byte {
73 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
74 encoded.push(byte as char);
75 }
76 _ => encoded.push_str(&format!("%{byte:02X}")),
77 }
78 }
79 encoded
80}
81
82/// `name=value` pairs as a form body.
Billing accounts, terms and enterprises; g1t is no longer free83/// A payment made with no one there.
84#[derive(Debug, Deserialize)]
85pub struct PaymentIntent {
86 pub id: String,
87 /// `succeeded`, or anything else when it did not go through.
88 pub status: String,
89 #[serde(default)]
90 pub amount_received: i64,
91}
92
Agents as a team: lifecycle, merge queue, billing and a new shell93pub(crate) fn form(fields: &[(&str, String)]) -> String {
94 fields
95 .iter()
96 .map(|(name, value)| format!("{}={}", encode(name), encode(value)))
97 .collect::<Vec<_>>()
98 .join("&")
99}
100
101impl Stripe {
102 pub fn new(key: String) -> Self {
103 Stripe { key }
104 }
105
106 /// Whether the key is for real cards, not Stripe's test mode.
107 pub fn live(&self) -> bool {
108 is_live(&self.key)
109 }
110
111 async fn call<T: for<'a> Deserialize<'a>>(
112 &self,
113 method: Method,
114 path: &str,
115 body: Option<String>,
116 ) -> Result<T> {
Billing accounts, terms and enterprises; g1t is no longer free117 self.send(method, path, body, None).await
118 }
119
120 async fn send<T: for<'a> Deserialize<'a>>(
121 &self,
122 method: Method,
123 path: &str,
124 body: Option<String>,
125 idempotency_key: Option<&str>,
126 ) -> Result<T> {
Agents as a team: lifecycle, merge queue, billing and a new shell127 let headers = Headers::new();
128 headers.set("authorization", &format!("Bearer {}", self.key))?;
Billing accounts, terms and enterprises; g1t is no longer free129 if let Some(key) = idempotency_key {
130 headers.set("idempotency-key", key)?;
131 }
Agents as a team: lifecycle, merge queue, billing and a new shell132 if body.is_some() {
133 headers.set("content-type", "application/x-www-form-urlencoded")?;
134 }
135 let mut init = RequestInit::new();
136 init.with_method(method).with_headers(headers);
137 if let Some(body) = body {
138 init.with_body(Some(body.into()));
139 }
140 let request = Request::new_with_init(&format!("{API}{path}"), &init)?;
141 let mut response = Fetch::Request(request).send().await?;
142 if response.status_code() != 200 {
143 return Err(Error::RustError(format!(
144 "the card processor answered {}: {}",
145 response.status_code(),
146 response.text().await.unwrap_or_default()
147 )));
148 }
149 response.json().await
150 }
151
Billing accounts, terms and enterprises; g1t is no longer free152 /// Charges the customer's saved card, with no one there: the automatic
153 /// payment at a workspace's limit. `key` makes a retry the same charge.
154 pub async fn charge_saved_card(
155 &self,
156 customer: &str,
157 amount_cents: i64,
158 description: &str,
159 key: &str,
160 ) -> Result<PaymentIntent> {
161 #[derive(Deserialize)]
162 struct Methods {
163 data: Vec<Method_>,
164 }
165 #[derive(Deserialize)]
166 struct Method_ {
167 id: String,
168 }
169 let methods: Methods = self
170 .call(Method::Get, &format!("/payment_methods?customer={}&type=card&limit=1", encode(customer)), None)
171 .await?;
172 let Some(card) = methods.data.first() else {
173 return Err(Error::RustError("no card on file".into()));
174 };
175 let fields = [
176 ("amount", amount_cents.to_string()),
177 ("currency", "usd".to_owned()),
178 ("customer", customer.to_owned()),
179 ("payment_method", card.id.clone()),
180 ("off_session", "true".to_owned()),
181 ("confirm", "true".to_owned()),
182 ("description", description.to_owned()),
183 ];
184 self.send(Method::Post, "/payment_intents", Some(form(&fields)), Some(key)).await
185 }
186
Agents as a team: lifecycle, merge queue, billing and a new shell187 /// Starts a page on which `amount_cents` of credit is paid for by card.
188 /// The card is kept for the workspace, so that topping up again, by
189 /// hand or automatically, needs no retyping.
190 pub async fn start_checkout(
191 &self,
192 workspace: &str,
193 amount_cents: u32,
194 customer: Option<&str>,
195 return_url: &str,
196 ) -> Result<Session> {
197 let separator = if return_url.contains('?') { '&' } else { '?' };
198 let mut fields = vec![
199 ("mode", "payment".to_owned()),
200 // Cards only: credit is bought on the spot, and the card is kept
201 // for topping up again.
202 ("payment_method_types[0]", "card".to_owned()),
203 (
204 "success_url",
205 // Stripe fills in the payment's id.
206 format!("{return_url}{separator}session={{CHECKOUT_SESSION_ID}}"),
207 ),
208 ("cancel_url", return_url.to_owned()),
209 ("client_reference_id", workspace.to_owned()),
210 ("metadata[workspace]", workspace.to_owned()),
211 ("line_items[0][quantity]", "1".to_owned()),
212 ("line_items[0][price_data][currency]", "usd".to_owned()),
213 (
214 "line_items[0][price_data][unit_amount]",
215 amount_cents.to_string(),
216 ),
217 (
218 "line_items[0][price_data][product_data][name]",
219 format!("g1t agent credit for {workspace}"),
220 ),
221 (
222 "payment_intent_data[setup_future_usage]",
223 "off_session".to_owned(),
224 ),
225 ];
226 match customer {
227 Some(customer) => fields.push(("customer", customer.to_owned())),
228 None => fields.push(("customer_creation", "always".to_owned())),
229 }
230 self.call(Method::Post, "/checkout/sessions", Some(form(&fields)))
231 .await
232 }
233
Paid features: a workspace turns on Deployments with a monthly plan234 /// Starts a page on which a feature's monthly plan is paid for by card.
235 pub async fn start_subscription(
236 &self,
237 workspace: &str,
238 feature: &str,
239 title: &str,
240 monthly_cents: u32,
241 customer: Option<&str>,
242 return_url: &str,
243 ) -> Result<Session> {
244 let separator = if return_url.contains('?') { '&' } else { '?' };
245 let mut fields = vec![
246 ("mode", "subscription".to_owned()),
247 ("payment_method_types[0]", "card".to_owned()),
248 (
249 "success_url",
250 format!("{return_url}{separator}session={{CHECKOUT_SESSION_ID}}"),
251 ),
252 ("cancel_url", return_url.to_owned()),
253 ("client_reference_id", workspace.to_owned()),
254 ("metadata[workspace]", workspace.to_owned()),
255 ("metadata[feature]", feature.to_owned()),
256 ("subscription_data[metadata][workspace]", workspace.to_owned()),
257 ("subscription_data[metadata][feature]", feature.to_owned()),
258 ("line_items[0][quantity]", "1".to_owned()),
259 ("line_items[0][price_data][currency]", "usd".to_owned()),
260 (
261 "line_items[0][price_data][unit_amount]",
262 monthly_cents.to_string(),
263 ),
264 (
265 "line_items[0][price_data][recurring][interval]",
266 "month".to_owned(),
267 ),
268 (
269 "line_items[0][price_data][product_data][name]",
270 format!("g1t {title} for {workspace}"),
271 ),
272 ];
273 if let Some(customer) = customer {
274 fields.push(("customer", customer.to_owned()));
275 }
276 self.call(Method::Post, "/checkout/sessions", Some(form(&fields)))
277 .await
278 }
279
280 pub async fn subscription(&self, id: &str) -> Result<StripeSubscription> {
281 self.call(Method::Get, &format!("/subscriptions/{}", encode(id)), None)
282 .await
283 }
284
285 /// Ends a plan when its period does (`cancel` true), or takes that back.
286 pub async fn cancel_at_period_end(&self, id: &str, cancel: bool) -> Result<StripeSubscription> {
287 self.call(
288 Method::Post,
289 &format!("/subscriptions/{}", encode(id)),
290 Some(form(&[("cancel_at_period_end", cancel.to_string())])),
291 )
292 .await
293 }
294
Agents as a team: lifecycle, merge queue, billing and a new shell295 pub async fn session(&self, id: &str) -> Result<Session> {
296 self.call(
297 Method::Get,
298 &format!("/checkout/sessions/{}", encode(id)),
299 None,
300 )
301 .await
302 }
303}
304
Project dependencies: addresses, preview stacks, Affects, and agents who know305/// Whether the processor said an id it was given does not exist, as when
306/// g1t moves to another Stripe account and ids saved from the old one stay
307/// behind.
308pub(crate) fn is_missing(error: &Error) -> bool {
309 error.to_string().contains("resource_missing")
310}
311
Agents as a team: lifecycle, merge queue, billing and a new shell312pub(crate) fn is_live(key: &str) -> bool {
313 key.starts_with("sk_live_") || key.starts_with("rk_live_")
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[test]
321 fn form_values_are_percent_encoded() {
322 assert_eq!(
323 form(&[
324 (
325 "success_url",
326 "https://g1t.sh/a/-/billing?session={ID}".to_owned()
327 ),
328 ("line_items[0][quantity]", "1".to_owned()),
329 ]),
330 "success_url=https%3A%2F%2Fg1t.sh%2Fa%2F-%2Fbilling%3Fsession%3D%7BID%7D&line_items%5B0%5D%5Bquantity%5D=1"
331 );
332 }
333
334 #[test]
335 fn test_keys_are_not_live() {
336 assert!(is_live("sk_live_abc"));
337 assert!(!is_live("sk_test_abc"));
338 assert!(!is_live(""));
339 }
340}