pr_01m47d24b0e6n91zwymwxg0vpx/services/billing/src/stripe.rs

467 lines16,152 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.
Billing accounts, terms and enterprises; g1t is no longer free107/// A payment made with no one there.
108#[derive(Debug, Deserialize)]
109pub struct PaymentIntent {
110 pub id: String,
111 /// `succeeded`, or anything else when it did not go through.
112 pub status: String,
113 #[serde(default)]
114 pub amount_received: i64,
115}
116
Agents as a team: lifecycle, merge queue, billing and a new shell117pub(crate) fn form(fields: &[(&str, String)]) -> String {
118 fields
119 .iter()
120 .map(|(name, value)| format!("{}={}", encode(name), encode(value)))
121 .collect::<Vec<_>>()
122 .join("&")
123}
124
125impl Stripe {
126 pub fn new(key: String) -> Self {
127 Stripe { key }
128 }
129
130 /// Whether the key is for real cards, not Stripe's test mode.
131 pub fn live(&self) -> bool {
132 is_live(&self.key)
133 }
134
Stripe webhooks, enterprise invoices, and sudo for both135 /// A GET of any Stripe resource, for the webhook handlers.
136 pub(crate) async fn get<T: for<'a> Deserialize<'a>>(&self, path: &str) -> Result<T> {
137 self.call(Method::Get, path, None).await
138 }
139
140 /// A form POST to any Stripe resource.
141 pub(crate) async fn post<T: for<'a> Deserialize<'a>>(&self, path: &str, fields: &[(&str, String)]) -> Result<T> {
142 self.call(Method::Post, path, Some(form(fields))).await
143 }
144
145 pub(crate) async fn delete<T: for<'a> Deserialize<'a>>(&self, path: &str) -> Result<T> {
146 self.call(Method::Delete, path, None).await
147 }
148
Agents as a team: lifecycle, merge queue, billing and a new shell149 async fn call<T: for<'a> Deserialize<'a>>(
150 &self,
151 method: Method,
152 path: &str,
153 body: Option<String>,
154 ) -> Result<T> {
Billing accounts, terms and enterprises; g1t is no longer free155 self.send(method, path, body, None).await
156 }
157
158 async fn send<T: for<'a> Deserialize<'a>>(
159 &self,
160 method: Method,
161 path: &str,
162 body: Option<String>,
163 idempotency_key: Option<&str>,
164 ) -> Result<T> {
Agents as a team: lifecycle, merge queue, billing and a new shell165 let headers = Headers::new();
166 headers.set("authorization", &format!("Bearer {}", self.key))?;
Billing accounts, terms and enterprises; g1t is no longer free167 if let Some(key) = idempotency_key {
168 headers.set("idempotency-key", key)?;
169 }
Agents as a team: lifecycle, merge queue, billing and a new shell170 if body.is_some() {
171 headers.set("content-type", "application/x-www-form-urlencoded")?;
172 }
173 let mut init = RequestInit::new();
174 init.with_method(method).with_headers(headers);
175 if let Some(body) = body {
176 init.with_body(Some(body.into()));
177 }
178 let request = Request::new_with_init(&format!("{API}{path}"), &init)?;
179 let mut response = Fetch::Request(request).send().await?;
180 if response.status_code() != 200 {
181 return Err(Error::RustError(format!(
182 "the card processor answered {}: {}",
183 response.status_code(),
184 response.text().await.unwrap_or_default()
185 )));
186 }
187 response.json().await
188 }
189
Billing accounts, terms and enterprises; g1t is no longer free190 /// Charges the customer's saved card, with no one there: the automatic
191 /// payment at a workspace's limit. `key` makes a retry the same charge.
192 pub async fn charge_saved_card(
193 &self,
194 customer: &str,
195 amount_cents: i64,
196 description: &str,
197 key: &str,
198 ) -> Result<PaymentIntent> {
199 #[derive(Deserialize)]
200 struct Methods {
201 data: Vec<Method_>,
202 }
203 #[derive(Deserialize)]
204 struct Method_ {
205 id: String,
206 }
207 let methods: Methods = self
208 .call(Method::Get, &format!("/payment_methods?customer={}&type=card&limit=1", encode(customer)), None)
209 .await?;
210 let Some(card) = methods.data.first() else {
211 return Err(Error::RustError("no card on file".into()));
212 };
213 let fields = [
214 ("amount", amount_cents.to_string()),
215 ("currency", "usd".to_owned()),
216 ("customer", customer.to_owned()),
217 ("payment_method", card.id.clone()),
218 ("off_session", "true".to_owned()),
219 ("confirm", "true".to_owned()),
220 ("description", description.to_owned()),
221 ];
222 self.send(Method::Post, "/payment_intents", Some(form(&fields)), Some(key)).await
223 }
224
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace225 /// A customer for a workspace that has none yet.
226 pub async fn create_customer(&self, workspace: &str) -> Result<String> {
227 #[derive(Deserialize)]
228 struct Customer {
229 id: String,
230 }
231 let fields = [
232 ("name", workspace.to_owned()),
233 ("metadata[workspace]", workspace.to_owned()),
234 ];
235 let customer: Customer = self.call(Method::Post, "/customers", Some(form(&fields))).await?;
236 Ok(customer.id)
237 }
238
239 /// A session on Stripe's hosted billing page (the customer portal) for
240 /// the customer, coming back to `return_url`.
241 pub async fn portal_session(&self, customer: &str, return_url: &str) -> Result<String> {
242 #[derive(Deserialize)]
243 struct Portal {
244 url: String,
245 }
246 let configuration = self.portal_configuration().await?;
247 let fields = [
248 ("customer", customer.to_owned()),
249 ("return_url", return_url.to_owned()),
250 ("configuration", configuration.id),
251 ];
252 let portal: Portal = self.call(Method::Post, "/billing_portal/sessions", Some(form(&fields))).await?;
253 Ok(portal.url)
254 }
255
256 /// g1t's billing page settings at Stripe, made the first time they are
257 /// needed: cards, invoices, billing details, and a sign-in page.
258 pub async fn portal_configuration(&self) -> Result<PortalConfiguration> {
259 #[derive(Deserialize)]
260 struct List {
261 data: Vec<PortalConfiguration>,
262 }
263 let list: List = self
264 .call(Method::Get, "/billing_portal/configurations?active=true&limit=20", None)
265 .await?;
266 if let Some(existing) = list
267 .data
268 .into_iter()
269 .find(|c| c.metadata.as_ref().and_then(|m| m.get("g1t")).is_some())
270 {
271 return Ok(existing);
272 }
273 let fields = [
274 ("business_profile[headline]", "g1t billing: your card, invoices and billing details".to_owned()),
275 ("features[payment_method_update][enabled]", "true".to_owned()),
276 ("features[invoice_history][enabled]", "true".to_owned()),
277 ("features[customer_update][enabled]", "true".to_owned()),
278 ("features[customer_update][allowed_updates][0]", "email".to_owned()),
279 ("features[customer_update][allowed_updates][1]", "address".to_owned()),
280 ("features[customer_update][allowed_updates][2]", "name".to_owned()),
281 ("features[customer_update][allowed_updates][3]", "tax_id".to_owned()),
282 ("login_page[enabled]", "true".to_owned()),
283 ("metadata[g1t]", "billing".to_owned()),
284 ];
285 self.call(Method::Post, "/billing_portal/configurations", Some(form(&fields))).await
286 }
287
288 /// The customer's email at Stripe, if they gave one.
289 pub async fn customer_email(&self, customer: &str) -> Result<Option<String>> {
290 #[derive(Deserialize)]
291 struct Customer {
292 email: Option<String>,
293 }
294 let found: Customer = self.call(Method::Get, &format!("/customers/{}", encode(customer)), None).await?;
295 Ok(found.email)
296 }
297
298 /// The customer's card, if one is saved.
299 pub async fn card(&self, customer: &str) -> Result<Option<SavedCard>> {
300 #[derive(Deserialize)]
301 struct Methods {
302 data: Vec<Method_>,
303 }
304 #[derive(Deserialize)]
305 struct Method_ {
306 card: Option<SavedCard>,
307 }
308 let methods: Methods = self
309 .call(Method::Get, &format!("/payment_methods?customer={}&type=card&limit=1", encode(customer)), None)
310 .await?;
311 Ok(methods.data.into_iter().next().and_then(|m| m.card))
312 }
313
Agents as a team: lifecycle, merge queue, billing and a new shell314 /// Starts a page on which `amount_cents` of credit is paid for by card.
315 /// The card is kept for the workspace, so that topping up again, by
316 /// hand or automatically, needs no retyping.
317 pub async fn start_checkout(
318 &self,
319 workspace: &str,
320 amount_cents: u32,
321 customer: Option<&str>,
322 return_url: &str,
323 ) -> Result<Session> {
324 let separator = if return_url.contains('?') { '&' } else { '?' };
325 let mut fields = vec![
326 ("mode", "payment".to_owned()),
327 // Cards only: credit is bought on the spot, and the card is kept
328 // for topping up again.
329 ("payment_method_types[0]", "card".to_owned()),
330 (
331 "success_url",
332 // Stripe fills in the payment's id.
333 format!("{return_url}{separator}session={{CHECKOUT_SESSION_ID}}"),
334 ),
335 ("cancel_url", return_url.to_owned()),
336 ("client_reference_id", workspace.to_owned()),
337 ("metadata[workspace]", workspace.to_owned()),
338 ("line_items[0][quantity]", "1".to_owned()),
339 ("line_items[0][price_data][currency]", "usd".to_owned()),
340 (
341 "line_items[0][price_data][unit_amount]",
342 amount_cents.to_string(),
343 ),
344 (
345 "line_items[0][price_data][product_data][name]",
346 format!("g1t agent credit for {workspace}"),
347 ),
348 (
349 "payment_intent_data[setup_future_usage]",
350 "off_session".to_owned(),
351 ),
352 ];
353 match customer {
354 Some(customer) => fields.push(("customer", customer.to_owned())),
355 None => fields.push(("customer_creation", "always".to_owned())),
356 }
357 self.call(Method::Post, "/checkout/sessions", Some(form(&fields)))
358 .await
359 }
360
Paid features: a workspace turns on Deployments with a monthly plan361 /// Starts a page on which a feature's monthly plan is paid for by card.
362 pub async fn start_subscription(
363 &self,
364 workspace: &str,
365 feature: &str,
366 title: &str,
367 monthly_cents: u32,
368 customer: Option<&str>,
369 return_url: &str,
370 ) -> Result<Session> {
371 let separator = if return_url.contains('?') { '&' } else { '?' };
372 let mut fields = vec![
373 ("mode", "subscription".to_owned()),
374 ("payment_method_types[0]", "card".to_owned()),
375 (
376 "success_url",
377 format!("{return_url}{separator}session={{CHECKOUT_SESSION_ID}}"),
378 ),
379 ("cancel_url", return_url.to_owned()),
380 ("client_reference_id", workspace.to_owned()),
381 ("metadata[workspace]", workspace.to_owned()),
382 ("metadata[feature]", feature.to_owned()),
383 ("subscription_data[metadata][workspace]", workspace.to_owned()),
384 ("subscription_data[metadata][feature]", feature.to_owned()),
385 ("line_items[0][quantity]", "1".to_owned()),
386 ("line_items[0][price_data][currency]", "usd".to_owned()),
387 (
388 "line_items[0][price_data][unit_amount]",
389 monthly_cents.to_string(),
390 ),
391 (
392 "line_items[0][price_data][recurring][interval]",
393 "month".to_owned(),
394 ),
395 (
396 "line_items[0][price_data][product_data][name]",
397 format!("g1t {title} for {workspace}"),
398 ),
399 ];
400 if let Some(customer) = customer {
401 fields.push(("customer", customer.to_owned()));
402 }
403 self.call(Method::Post, "/checkout/sessions", Some(form(&fields)))
404 .await
405 }
406
407 pub async fn subscription(&self, id: &str) -> Result<StripeSubscription> {
408 self.call(Method::Get, &format!("/subscriptions/{}", encode(id)), None)
409 .await
410 }
411
412 /// Ends a plan when its period does (`cancel` true), or takes that back.
413 pub async fn cancel_at_period_end(&self, id: &str, cancel: bool) -> Result<StripeSubscription> {
414 self.call(
415 Method::Post,
416 &format!("/subscriptions/{}", encode(id)),
417 Some(form(&[("cancel_at_period_end", cancel.to_string())])),
418 )
419 .await
420 }
421
Agents as a team: lifecycle, merge queue, billing and a new shell422 pub async fn session(&self, id: &str) -> Result<Session> {
423 self.call(
424 Method::Get,
425 &format!("/checkout/sessions/{}", encode(id)),
426 None,
427 )
428 .await
429 }
430}
431
Project dependencies: addresses, preview stacks, Affects, and agents who know432/// Whether the processor said an id it was given does not exist, as when
433/// g1t moves to another Stripe account and ids saved from the old one stay
434/// behind.
435pub(crate) fn is_missing(error: &Error) -> bool {
436 error.to_string().contains("resource_missing")
437}
438
Agents as a team: lifecycle, merge queue, billing and a new shell439pub(crate) fn is_live(key: &str) -> bool {
440 key.starts_with("sk_live_") || key.starts_with("rk_live_")
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446
447 #[test]
448 fn form_values_are_percent_encoded() {
449 assert_eq!(
450 form(&[
451 (
452 "success_url",
453 "https://g1t.sh/a/-/billing?session={ID}".to_owned()
454 ),
455 ("line_items[0][quantity]", "1".to_owned()),
456 ]),
457 "success_url=https%3A%2F%2Fg1t.sh%2Fa%2F-%2Fbilling%3Fsession%3D%7BID%7D&line_items%5B0%5D%5Bquantity%5D=1"
458 );
459 }
460
461 #[test]
462 fn test_keys_are_not_live() {
463 assert!(is_live("sk_live_abc"));
464 assert!(!is_live("sk_test_abc"));
465 assert!(!is_live(""));
466 }
467}