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

433 lines14,956 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
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace30/// g1t's settings for Stripe's hosted billing page.
31#[derive(Debug, Deserialize)]
32pub struct PortalConfiguration {
33 pub id: String,
34 #[serde(default)]
35 pub login_page: Option<LoginPage>,
36 #[serde(default)]
37 pub metadata: Option<std::collections::HashMap<String, String>>,
38}
39
40#[derive(Debug, Deserialize)]
41pub struct LoginPage {
42 pub url: Option<String>,
43}
44
45/// A saved card's details.
46#[derive(Debug, Deserialize)]
47pub struct SavedCard {
48 pub brand: String,
49 pub last4: String,
50 pub exp_month: u32,
51 pub exp_year: u32,
52}
53
Paid features: a workspace turns on Deployments with a monthly plan54/// A monthly plan.
55#[derive(Deserialize)]
56pub struct StripeSubscription {
57 pub id: String,
58 /// `active`, `trialing`, `past_due`, `unpaid`, `canceled`, `incomplete`…
59 pub status: String,
60 #[serde(default)]
61 pub cancel_at_period_end: bool,
62 /// Unix seconds. Older API versions carry it here…
63 #[serde(default)]
64 pub current_period_end: Option<i64>,
65 /// …newer ones on each item.
66 #[serde(default)]
67 pub items: Option<Items>,
68}
69
70#[derive(Deserialize)]
71pub struct Items {
72 pub data: Vec<Item>,
73}
74
75#[derive(Deserialize)]
76pub struct Item {
77 #[serde(default)]
78 pub current_period_end: Option<i64>,
79}
80
81impl StripeSubscription {
82 /// When the period paid for ends, in Unix seconds.
83 pub fn period_end(&self) -> Option<i64> {
84 self.current_period_end.or_else(|| {
85 self.items
86 .as_ref()
87 .and_then(|items| items.data.iter().filter_map(|item| item.current_period_end).max())
88 })
89 }
90}
91
Agents as a team: lifecycle, merge queue, billing and a new shell92/// Percent-encodes a form value.
93fn encode(value: &str) -> String {
94 let mut encoded = String::with_capacity(value.len());
95 for byte in value.bytes() {
96 match byte {
97 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
98 encoded.push(byte as char);
99 }
100 _ => encoded.push_str(&format!("%{byte:02X}")),
101 }
102 }
103 encoded
104}
105
106/// `name=value` pairs as a form body.
107pub(crate) fn form(fields: &[(&str, String)]) -> String {
108 fields
109 .iter()
110 .map(|(name, value)| format!("{}={}", encode(name), encode(value)))
111 .collect::<Vec<_>>()
112 .join("&")
113}
114
115impl Stripe {
116 pub fn new(key: String) -> Self {
117 Stripe { key }
118 }
119
120 /// Whether the key is for real cards, not Stripe's test mode.
121 pub fn live(&self) -> bool {
122 is_live(&self.key)
123 }
124
Stripe webhooks, enterprise invoices, and sudo for both125 /// A GET of any Stripe resource, for the webhook handlers.
126 pub(crate) async fn get<T: for<'a> Deserialize<'a>>(&self, path: &str) -> Result<T> {
127 self.call(Method::Get, path, None).await
128 }
129
130 /// A form POST to any Stripe resource.
131 pub(crate) async fn post<T: for<'a> Deserialize<'a>>(&self, path: &str, fields: &[(&str, String)]) -> Result<T> {
132 self.call(Method::Post, path, Some(form(fields))).await
133 }
134
Two limits, real invoices, trust that grows by itself, sales signals135 /// A form POST that Stripe does at most once for `key`, however often
136 /// it is sent.
137 pub(crate) async fn post_idempotent<T: for<'a> Deserialize<'a>>(
138 &self,
139 path: &str,
140 fields: &[(&str, String)],
141 key: &str,
142 ) -> Result<T> {
143 self.send(Method::Post, path, Some(form(fields)), Some(key)).await
144 }
145
Stripe webhooks, enterprise invoices, and sudo for both146 pub(crate) async fn delete<T: for<'a> Deserialize<'a>>(&self, path: &str) -> Result<T> {
147 self.call(Method::Delete, path, None).await
148 }
149
Agents as a team: lifecycle, merge queue, billing and a new shell150 async fn call<T: for<'a> Deserialize<'a>>(
151 &self,
152 method: Method,
153 path: &str,
154 body: Option<String>,
155 ) -> Result<T> {
Billing accounts, terms and enterprises; g1t is no longer free156 self.send(method, path, body, None).await
157 }
158
159 async fn send<T: for<'a> Deserialize<'a>>(
160 &self,
161 method: Method,
162 path: &str,
163 body: Option<String>,
164 idempotency_key: Option<&str>,
165 ) -> Result<T> {
Agents as a team: lifecycle, merge queue, billing and a new shell166 let headers = Headers::new();
167 headers.set("authorization", &format!("Bearer {}", self.key))?;
Billing accounts, terms and enterprises; g1t is no longer free168 if let Some(key) = idempotency_key {
169 headers.set("idempotency-key", key)?;
170 }
Agents as a team: lifecycle, merge queue, billing and a new shell171 if body.is_some() {
172 headers.set("content-type", "application/x-www-form-urlencoded")?;
173 }
174 let mut init = RequestInit::new();
175 init.with_method(method).with_headers(headers);
176 if let Some(body) = body {
177 init.with_body(Some(body.into()));
178 }
179 let request = Request::new_with_init(&format!("{API}{path}"), &init)?;
180 let mut response = Fetch::Request(request).send().await?;
181 if response.status_code() != 200 {
182 return Err(Error::RustError(format!(
183 "the card processor answered {}: {}",
184 response.status_code(),
185 response.text().await.unwrap_or_default()
186 )));
187 }
188 response.json().await
Billing accounts, terms and enterprises; g1t is no longer free189 }
190
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace191 /// A customer for a workspace that has none yet.
192 pub async fn create_customer(&self, workspace: &str) -> Result<String> {
193 #[derive(Deserialize)]
194 struct Customer {
195 id: String,
196 }
197 let fields = [
198 ("name", workspace.to_owned()),
199 ("metadata[workspace]", workspace.to_owned()),
200 ];
201 let customer: Customer = self.call(Method::Post, "/customers", Some(form(&fields))).await?;
202 Ok(customer.id)
203 }
204
205 /// A session on Stripe's hosted billing page (the customer portal) for
206 /// the customer, coming back to `return_url`.
207 pub async fn portal_session(&self, customer: &str, return_url: &str) -> Result<String> {
208 #[derive(Deserialize)]
209 struct Portal {
210 url: String,
211 }
212 let configuration = self.portal_configuration().await?;
213 let fields = [
214 ("customer", customer.to_owned()),
215 ("return_url", return_url.to_owned()),
216 ("configuration", configuration.id),
217 ];
218 let portal: Portal = self.call(Method::Post, "/billing_portal/sessions", Some(form(&fields))).await?;
219 Ok(portal.url)
220 }
221
222 /// g1t's billing page settings at Stripe, made the first time they are
223 /// needed: cards, invoices, billing details, and a sign-in page.
224 pub async fn portal_configuration(&self) -> Result<PortalConfiguration> {
225 #[derive(Deserialize)]
226 struct List {
227 data: Vec<PortalConfiguration>,
228 }
229 let list: List = self
230 .call(Method::Get, "/billing_portal/configurations?active=true&limit=20", None)
231 .await?;
232 if let Some(existing) = list
233 .data
234 .into_iter()
235 .find(|c| c.metadata.as_ref().and_then(|m| m.get("g1t")).is_some())
236 {
237 return Ok(existing);
238 }
239 let fields = [
240 ("business_profile[headline]", "g1t billing: your card, invoices and billing details".to_owned()),
241 ("features[payment_method_update][enabled]", "true".to_owned()),
242 ("features[invoice_history][enabled]", "true".to_owned()),
243 ("features[customer_update][enabled]", "true".to_owned()),
244 ("features[customer_update][allowed_updates][0]", "email".to_owned()),
245 ("features[customer_update][allowed_updates][1]", "address".to_owned()),
246 ("features[customer_update][allowed_updates][2]", "name".to_owned()),
247 ("features[customer_update][allowed_updates][3]", "tax_id".to_owned()),
248 ("login_page[enabled]", "true".to_owned()),
249 ("metadata[g1t]", "billing".to_owned()),
250 ];
251 self.call(Method::Post, "/billing_portal/configurations", Some(form(&fields))).await
252 }
253
254 /// The customer's email at Stripe, if they gave one.
255 pub async fn customer_email(&self, customer: &str) -> Result<Option<String>> {
256 #[derive(Deserialize)]
257 struct Customer {
258 email: Option<String>,
259 }
260 let found: Customer = self.call(Method::Get, &format!("/customers/{}", encode(customer)), None).await?;
261 Ok(found.email)
262 }
263
264 /// The customer's card, if one is saved.
265 pub async fn card(&self, customer: &str) -> Result<Option<SavedCard>> {
266 #[derive(Deserialize)]
267 struct Methods {
268 data: Vec<Method_>,
269 }
270 #[derive(Deserialize)]
271 struct Method_ {
272 card: Option<SavedCard>,
273 }
274 let methods: Methods = self
275 .call(Method::Get, &format!("/payment_methods?customer={}&type=card&limit=1", encode(customer)), None)
276 .await?;
277 Ok(methods.data.into_iter().next().and_then(|m| m.card))
278 }
279
Agents as a team: lifecycle, merge queue, billing and a new shell280 /// Starts a page on which `amount_cents` of credit is paid for by card.
281 /// The card is kept for the workspace, so that topping up again, by
282 /// hand or automatically, needs no retyping.
283 pub async fn start_checkout(
284 &self,
285 workspace: &str,
286 amount_cents: u32,
287 customer: Option<&str>,
288 return_url: &str,
289 ) -> Result<Session> {
290 let separator = if return_url.contains('?') { '&' } else { '?' };
291 let mut fields = vec![
292 ("mode", "payment".to_owned()),
293 // Cards only: credit is bought on the spot, and the card is kept
294 // for topping up again.
295 ("payment_method_types[0]", "card".to_owned()),
296 (
297 "success_url",
298 // Stripe fills in the payment's id.
299 format!("{return_url}{separator}session={{CHECKOUT_SESSION_ID}}"),
300 ),
301 ("cancel_url", return_url.to_owned()),
302 ("client_reference_id", workspace.to_owned()),
303 ("metadata[workspace]", workspace.to_owned()),
304 ("line_items[0][quantity]", "1".to_owned()),
305 ("line_items[0][price_data][currency]", "usd".to_owned()),
306 (
307 "line_items[0][price_data][unit_amount]",
308 amount_cents.to_string(),
309 ),
310 (
311 "line_items[0][price_data][product_data][name]",
312 format!("g1t agent credit for {workspace}"),
313 ),
314 (
315 "payment_intent_data[setup_future_usage]",
316 "off_session".to_owned(),
317 ),
318 ];
319 match customer {
320 Some(customer) => fields.push(("customer", customer.to_owned())),
321 None => fields.push(("customer_creation", "always".to_owned())),
322 }
323 self.call(Method::Post, "/checkout/sessions", Some(form(&fields)))
324 .await
325 }
326
Paid features: a workspace turns on Deployments with a monthly plan327 /// Starts a page on which a feature's monthly plan is paid for by card.
328 pub async fn start_subscription(
329 &self,
330 workspace: &str,
331 feature: &str,
332 title: &str,
333 monthly_cents: u32,
334 customer: Option<&str>,
335 return_url: &str,
336 ) -> Result<Session> {
337 let separator = if return_url.contains('?') { '&' } else { '?' };
338 let mut fields = vec![
339 ("mode", "subscription".to_owned()),
340 ("payment_method_types[0]", "card".to_owned()),
341 (
342 "success_url",
343 format!("{return_url}{separator}session={{CHECKOUT_SESSION_ID}}"),
344 ),
345 ("cancel_url", return_url.to_owned()),
346 ("client_reference_id", workspace.to_owned()),
347 ("metadata[workspace]", workspace.to_owned()),
348 ("metadata[feature]", feature.to_owned()),
349 ("subscription_data[metadata][workspace]", workspace.to_owned()),
350 ("subscription_data[metadata][feature]", feature.to_owned()),
351 ("line_items[0][quantity]", "1".to_owned()),
352 ("line_items[0][price_data][currency]", "usd".to_owned()),
353 (
354 "line_items[0][price_data][unit_amount]",
355 monthly_cents.to_string(),
356 ),
357 (
358 "line_items[0][price_data][recurring][interval]",
359 "month".to_owned(),
360 ),
361 (
362 "line_items[0][price_data][product_data][name]",
363 format!("g1t {title} for {workspace}"),
364 ),
365 ];
366 if let Some(customer) = customer {
367 fields.push(("customer", customer.to_owned()));
368 }
369 self.call(Method::Post, "/checkout/sessions", Some(form(&fields)))
370 .await
371 }
372
373 pub async fn subscription(&self, id: &str) -> Result<StripeSubscription> {
374 self.call(Method::Get, &format!("/subscriptions/{}", encode(id)), None)
375 .await
376 }
377
378 /// Ends a plan when its period does (`cancel` true), or takes that back.
379 pub async fn cancel_at_period_end(&self, id: &str, cancel: bool) -> Result<StripeSubscription> {
380 self.call(
381 Method::Post,
382 &format!("/subscriptions/{}", encode(id)),
383 Some(form(&[("cancel_at_period_end", cancel.to_string())])),
384 )
385 .await
386 }
387
Agents as a team: lifecycle, merge queue, billing and a new shell388 pub async fn session(&self, id: &str) -> Result<Session> {
389 self.call(
390 Method::Get,
391 &format!("/checkout/sessions/{}", encode(id)),
392 None,
393 )
394 .await
395 }
396}
397
Project dependencies: addresses, preview stacks, Affects, and agents who know398/// Whether the processor said an id it was given does not exist, as when
399/// g1t moves to another Stripe account and ids saved from the old one stay
400/// behind.
401pub(crate) fn is_missing(error: &Error) -> bool {
402 error.to_string().contains("resource_missing")
403}
404
Agents as a team: lifecycle, merge queue, billing and a new shell405pub(crate) fn is_live(key: &str) -> bool {
406 key.starts_with("sk_live_") || key.starts_with("rk_live_")
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 #[test]
414 fn form_values_are_percent_encoded() {
415 assert_eq!(
416 form(&[
417 (
418 "success_url",
419 "https://g1t.sh/a/-/billing?session={ID}".to_owned()
420 ),
421 ("line_items[0][quantity]", "1".to_owned()),
422 ]),
423 "success_url=https%3A%2F%2Fg1t.sh%2Fa%2F-%2Fbilling%3Fsession%3D%7BID%7D&line_items%5B0%5D%5Bquantity%5D=1"
424 );
425 }
426
427 #[test]
428 fn test_keys_are_not_live() {
429 assert!(is_live("sk_live_abc"));
430 assert!(!is_live("sk_test_abc"));
431 assert!(!is_live(""));
432 }
433}