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

588 lines22,025 bytesCodeBlame
1//! The card processor, behind the calls billing needs: start a payment
2//! page, save and verify a card, ask whether a payment was made, and start,
3//! read or end the monthly plan. Stripe speaks form-encoded requests and
4//! JSON answers.
5//!
6//! Every Stripe object billing needs beyond customers and their payments
7//! (the plan's product, the billing page's settings) is made the first time
8//! it is needed, and found again by its `metadata[g1t]` after that.
9
10use serde::Deserialize;
11use worker::{Error, Fetch, Headers, Method, Request, RequestInit, Result};
12
13const API: &str = "https://api.stripe.com/v1";
14
15pub struct Stripe {
16 key: String,
17}
18
19/// A payment page, and the payment made through it.
20#[derive(Deserialize)]
21pub struct Session {
22 pub id: String,
23 /// Where to send the person. Absent once the page has been used.
24 pub url: Option<String>,
25 /// `paid` once the money has been taken.
26 pub payment_status: String,
27 /// What was paid, in cents.
28 pub amount_total: Option<u32>,
29 pub customer: Option<String>,
30 /// For a plan's page: the subscription it started.
31 #[serde(default)]
32 pub subscription: Option<String>,
33 /// For a card check's page: the setup that saved and verified the card.
34 #[serde(default)]
35 pub setup_intent: Option<String>,
36}
37
38/// A card saved and verified: what a card check found.
39#[derive(Debug, Deserialize)]
40pub struct CheckedCard {
41 pub payment_method: String,
42 pub fingerprint: Option<String>,
43 pub brand: Option<String>,
44 pub last4: Option<String>,
45 /// `credit`, `debit`, `prepaid` or `unknown`.
46 pub funding: Option<String>,
47 pub country: Option<String>,
48}
49
50/// g1t's settings for Stripe's hosted billing page.
51#[derive(Debug, Deserialize)]
52pub struct PortalConfiguration {
53 pub id: String,
54 #[serde(default)]
55 pub login_page: Option<LoginPage>,
56 #[serde(default)]
57 pub metadata: Option<std::collections::HashMap<String, String>>,
58}
59
60#[derive(Debug, Deserialize)]
61pub struct LoginPage {
62 pub url: Option<String>,
63}
64
65/// A saved card's details.
66#[derive(Debug, Deserialize)]
67pub struct SavedCard {
68 pub brand: String,
69 pub last4: String,
70 pub exp_month: u32,
71 pub exp_year: u32,
72}
73
74/// A monthly plan.
75#[derive(Deserialize)]
76pub struct StripeSubscription {
77 pub id: String,
78 /// `active`, `trialing`, `past_due`, `unpaid`, `canceled`, `incomplete`…
79 pub status: String,
80 #[serde(default)]
81 pub cancel_at_period_end: bool,
82 /// Unix seconds. Older API versions carry it here…
83 #[serde(default)]
84 pub current_period_end: Option<i64>,
85 /// …newer ones on each item.
86 #[serde(default)]
87 pub items: Option<Items>,
88}
89
90#[derive(Deserialize)]
91pub struct Items {
92 pub data: Vec<Item>,
93}
94
95#[derive(Deserialize)]
96pub struct Item {
97 #[serde(default)]
98 pub current_period_end: Option<i64>,
99}
100
101impl StripeSubscription {
102 /// When the period paid for ends, in Unix seconds.
103 pub fn period_end(&self) -> Option<i64> {
104 self.current_period_end.or_else(|| {
105 self.items
106 .as_ref()
107 .and_then(|items| items.data.iter().filter_map(|item| item.current_period_end).max())
108 })
109 }
110}
111
112/// Percent-encodes a form value.
113fn encode(value: &str) -> String {
114 let mut encoded = String::with_capacity(value.len());
115 for byte in value.bytes() {
116 match byte {
117 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
118 encoded.push(byte as char);
119 }
120 _ => encoded.push_str(&format!("%{byte:02X}")),
121 }
122 }
123 encoded
124}
125
126/// `name=value` pairs as a form body.
127pub(crate) fn form(fields: &[(&str, String)]) -> String {
128 fields
129 .iter()
130 .map(|(name, value)| format!("{}={}", encode(name), encode(value)))
131 .collect::<Vec<_>>()
132 .join("&")
133}
134
135impl Stripe {
136 pub fn new(key: String) -> Self {
137 Stripe { key }
138 }
139
140 /// Whether the key is for real cards, not Stripe's test mode.
141 pub fn live(&self) -> bool {
142 is_live(&self.key)
143 }
144
145 /// A GET of any Stripe resource, for the webhook handlers.
146 pub(crate) async fn get<T: for<'a> Deserialize<'a>>(&self, path: &str) -> Result<T> {
147 self.call(Method::Get, path, None).await
148 }
149
150 /// A form POST to any Stripe resource.
151 pub(crate) async fn post<T: for<'a> Deserialize<'a>>(&self, path: &str, fields: &[(&str, String)]) -> Result<T> {
152 self.call(Method::Post, path, Some(form(fields))).await
153 }
154
155 /// A form POST that Stripe does at most once for `key`, however often
156 /// it is sent.
157 pub(crate) async fn post_idempotent<T: for<'a> Deserialize<'a>>(
158 &self,
159 path: &str,
160 fields: &[(&str, String)],
161 key: &str,
162 ) -> Result<T> {
163 self.send(Method::Post, path, Some(form(fields)), Some(key)).await
164 }
165
166 pub(crate) async fn delete<T: for<'a> Deserialize<'a>>(&self, path: &str) -> Result<T> {
167 self.call(Method::Delete, path, None).await
168 }
169
170 async fn call<T: for<'a> Deserialize<'a>>(
171 &self,
172 method: Method,
173 path: &str,
174 body: Option<String>,
175 ) -> Result<T> {
176 self.send(method, path, body, None).await
177 }
178
179 async fn send<T: for<'a> Deserialize<'a>>(
180 &self,
181 method: Method,
182 path: &str,
183 body: Option<String>,
184 idempotency_key: Option<&str>,
185 ) -> Result<T> {
186 let headers = Headers::new();
187 headers.set("authorization", &format!("Bearer {}", self.key))?;
188 if let Some(key) = idempotency_key {
189 headers.set("idempotency-key", key)?;
190 }
191 if body.is_some() {
192 headers.set("content-type", "application/x-www-form-urlencoded")?;
193 }
194 let mut init = RequestInit::new();
195 init.with_method(method).with_headers(headers);
196 if let Some(body) = body {
197 init.with_body(Some(body.into()));
198 }
199 let request = Request::new_with_init(&format!("{API}{path}"), &init)?;
200 let mut response = Fetch::Request(request).send().await?;
201 if response.status_code() != 200 {
202 return Err(Error::RustError(format!(
203 "the card processor answered {}: {}",
204 response.status_code(),
205 response.text().await.unwrap_or_default()
206 )));
207 }
208 response.json().await
209 }
210
211 /// A customer for a workspace that has none yet.
212 pub async fn create_customer(&self, workspace: &str) -> Result<String> {
213 #[derive(Deserialize)]
214 struct Customer {
215 id: String,
216 }
217 let fields = [
218 ("name", workspace.to_owned()),
219 ("metadata[workspace]", workspace.to_owned()),
220 ];
221 let customer: Customer = self.call(Method::Post, "/customers", Some(form(&fields))).await?;
222 Ok(customer.id)
223 }
224
225 /// A session on Stripe's hosted billing page (the customer portal) for
226 /// the customer, coming back to `return_url`.
227 pub async fn portal_session(&self, customer: &str, return_url: &str) -> Result<String> {
228 #[derive(Deserialize)]
229 struct Portal {
230 url: String,
231 }
232 let configuration = self.portal_configuration().await?;
233 let fields = [
234 ("customer", customer.to_owned()),
235 ("return_url", return_url.to_owned()),
236 ("configuration", configuration.id),
237 ];
238 let portal: Portal = self.call(Method::Post, "/billing_portal/sessions", Some(form(&fields))).await?;
239 Ok(portal.url)
240 }
241
242 /// g1t's billing page settings at Stripe, made the first time they are
243 /// needed: cards, invoices, billing details, and a sign-in page.
244 pub async fn portal_configuration(&self) -> Result<PortalConfiguration> {
245 #[derive(Deserialize)]
246 struct List {
247 data: Vec<PortalConfiguration>,
248 }
249 let list: List = self
250 .call(Method::Get, "/billing_portal/configurations?active=true&limit=20", None)
251 .await?;
252 if let Some(existing) = list
253 .data
254 .into_iter()
255 .find(|c| c.metadata.as_ref().and_then(|m| m.get("g1t")).is_some())
256 {
257 return Ok(existing);
258 }
259 let fields = [
260 ("business_profile[headline]", "g1t billing: your card, invoices and billing details".to_owned()),
261 ("features[payment_method_update][enabled]", "true".to_owned()),
262 ("features[invoice_history][enabled]", "true".to_owned()),
263 ("features[customer_update][enabled]", "true".to_owned()),
264 ("features[customer_update][allowed_updates][0]", "email".to_owned()),
265 ("features[customer_update][allowed_updates][1]", "address".to_owned()),
266 ("features[customer_update][allowed_updates][2]", "name".to_owned()),
267 ("features[customer_update][allowed_updates][3]", "tax_id".to_owned()),
268 ("login_page[enabled]", "true".to_owned()),
269 ("metadata[g1t]", "billing".to_owned()),
270 ];
271 self.call(Method::Post, "/billing_portal/configurations", Some(form(&fields))).await
272 }
273
274 /// The customer's email at Stripe, if they gave one.
275 pub async fn customer_email(&self, customer: &str) -> Result<Option<String>> {
276 #[derive(Deserialize)]
277 struct Customer {
278 email: Option<String>,
279 }
280 let found: Customer = self.call(Method::Get, &format!("/customers/{}", encode(customer)), None).await?;
281 Ok(found.email)
282 }
283
284 /// The customer's card, if one is saved.
285 pub async fn card(&self, customer: &str) -> Result<Option<SavedCard>> {
286 #[derive(Deserialize)]
287 struct Methods {
288 data: Vec<Method_>,
289 }
290 #[derive(Deserialize)]
291 struct Method_ {
292 card: Option<SavedCard>,
293 }
294 let methods: Methods = self
295 .call(Method::Get, &format!("/payment_methods?customer={}&type=card&limit=1", encode(customer)), None)
296 .await?;
297 Ok(methods.data.into_iter().next().and_then(|m| m.card))
298 }
299
300 /// Starts a page on which `amount_cents` is paid in advance: by card,
301 /// with 3-D Secure asked for wherever the card supports it, the card
302 /// kept for later charges; or, with `bank_transfer` and a customer, by
303 /// bank transfer to the account details Stripe gives, counted when the
304 /// money arrives.
305 pub async fn start_checkout(
306 &self,
307 workspace: &str,
308 amount_cents: u32,
309 customer: Option<&str>,
310 return_url: &str,
311 bank_transfer: bool,
312 ) -> Result<Session> {
313 let separator = if return_url.contains('?') { '&' } else { '?' };
314 let mut fields = vec![("mode", "payment".to_owned())];
315 if bank_transfer {
316 fields.extend([
317 ("payment_method_types[0]", "customer_balance".to_owned()),
318 ("payment_method_options[customer_balance][funding_type]", "bank_transfer".to_owned()),
319 ("payment_method_options[customer_balance][bank_transfer][type]", "us_bank_transfer".to_owned()),
320 ]);
321 } else {
322 fields.extend([
323 ("payment_method_types[0]", "card".to_owned()),
324 ("payment_method_options[card][request_three_d_secure]", "any".to_owned()),
325 ("payment_intent_data[setup_future_usage]", "off_session".to_owned()),
326 ]);
327 }
328 fields.extend([
329 (
330 "success_url",
331 // Stripe fills in the payment's id.
332 format!("{return_url}{separator}session={{CHECKOUT_SESSION_ID}}"),
333 ),
334 ("cancel_url", return_url.to_owned()),
335 ("client_reference_id", workspace.to_owned()),
336 ("metadata[workspace]", workspace.to_owned()),
337 ("line_items[0][quantity]", "1".to_owned()),
338 ("line_items[0][price_data][currency]", "usd".to_owned()),
339 (
340 "line_items[0][price_data][unit_amount]",
341 amount_cents.to_string(),
342 ),
343 (
344 "line_items[0][price_data][product_data][name]",
345 format!("g1t usage paid in advance for {workspace}"),
346 ),
347 ]);
348 match customer {
349 Some(customer) => fields.push(("customer", customer.to_owned())),
350 None => fields.push(("customer_creation", "always".to_owned())),
351 }
352 self.call(Method::Post, "/checkout/sessions", Some(form(&fields)))
353 .await
354 }
355
356 /// Starts a page on which a feature's monthly plan is paid for by card.
357 pub async fn start_subscription(
358 &self,
359 workspace: &str,
360 feature: &str,
361 title: &str,
362 monthly_cents: u32,
363 customer: Option<&str>,
364 return_url: &str,
365 ) -> Result<Session> {
366 let separator = if return_url.contains('?') { '&' } else { '?' };
367 let mut fields = vec![
368 ("mode", "subscription".to_owned()),
369 ("payment_method_types[0]", "card".to_owned()),
370 (
371 "success_url",
372 format!("{return_url}{separator}session={{CHECKOUT_SESSION_ID}}"),
373 ),
374 ("cancel_url", return_url.to_owned()),
375 ("client_reference_id", workspace.to_owned()),
376 ("metadata[workspace]", workspace.to_owned()),
377 ("metadata[feature]", feature.to_owned()),
378 ("subscription_data[metadata][workspace]", workspace.to_owned()),
379 ("subscription_data[metadata][feature]", feature.to_owned()),
380 ("line_items[0][quantity]", "1".to_owned()),
381 ("line_items[0][price_data][currency]", "usd".to_owned()),
382 (
383 "line_items[0][price_data][unit_amount]",
384 monthly_cents.to_string(),
385 ),
386 (
387 "line_items[0][price_data][recurring][interval]",
388 "month".to_owned(),
389 ),
390 (
391 "line_items[0][price_data][product_data][name]",
392 format!("g1t {title} for {workspace}"),
393 ),
394 ];
395 if let Some(customer) = customer {
396 fields.push(("customer", customer.to_owned()));
397 }
398 self.call(Method::Post, "/checkout/sessions", Some(form(&fields)))
399 .await
400 }
401
402 /// Starts a page that saves and verifies a card, with 3-D Secure asked
403 /// for wherever the card supports it. Nothing is charged: the card's
404 /// bank sees at most a $0 or $1 authorization that is never captured.
405 pub async fn start_card_check(&self, workspace: &str, customer: &str, return_url: &str) -> Result<Session> {
406 let separator = if return_url.contains('?') { '&' } else { '?' };
407 let fields = [
408 ("mode", "setup".to_owned()),
409 ("customer", customer.to_owned()),
410 ("payment_method_types[0]", "card".to_owned()),
411 ("payment_method_options[card][request_three_d_secure]", "any".to_owned()),
412 ("success_url", format!("{return_url}{separator}card_check={{CHECKOUT_SESSION_ID}}")),
413 ("cancel_url", return_url.to_owned()),
414 ("client_reference_id", workspace.to_owned()),
415 ("metadata[workspace]", workspace.to_owned()),
416 ("metadata[purpose]", "card_check".to_owned()),
417 ("setup_intent_data[metadata][workspace]", workspace.to_owned()),
418 ("setup_intent_data[description]", format!("Card check for g1t workspace {workspace}; never charged")),
419 ];
420 self.call(Method::Post, "/checkout/sessions", Some(form(&fields))).await
421 }
422
423 /// What a card check's setup found, once it succeeded.
424 pub async fn checked_card(&self, setup_intent: &str) -> Result<Option<CheckedCard>> {
425 #[derive(Deserialize)]
426 struct Setup {
427 status: String,
428 payment_method: Option<String>,
429 }
430 #[derive(Deserialize)]
431 struct Card {
432 fingerprint: Option<String>,
433 brand: Option<String>,
434 last4: Option<String>,
435 funding: Option<String>,
436 country: Option<String>,
437 }
438 #[derive(Deserialize)]
439 struct PaymentMethod {
440 card: Option<Card>,
441 }
442 let setup: Setup = self.call(Method::Get, &format!("/setup_intents/{}", encode(setup_intent)), None).await?;
443 let (true, Some(method)) = (setup.status == "succeeded", setup.payment_method) else { return Ok(None) };
444 let found: PaymentMethod = self.call(Method::Get, &format!("/payment_methods/{}", encode(&method)), None).await?;
445 let card = found.card;
446 Ok(Some(CheckedCard {
447 payment_method: method,
448 fingerprint: card.as_ref().and_then(|c| c.fingerprint.clone()),
449 brand: card.as_ref().and_then(|c| c.brand.clone()),
450 last4: card.as_ref().and_then(|c| c.last4.clone()),
451 funding: card.as_ref().and_then(|c| c.funding.clone()),
452 country: card.as_ref().and_then(|c| c.country.clone()),
453 }))
454 }
455
456 /// Makes `payment_method` the card the customer's invoices are charged to.
457 pub async fn set_default_card(&self, customer: &str, payment_method: &str) -> Result<()> {
458 let _: serde_json::Value = self
459 .call(
460 Method::Post,
461 &format!("/customers/{}", encode(customer)),
462 Some(form(&[("invoice_settings[default_payment_method]", payment_method.to_owned())])),
463 )
464 .await?;
465 Ok(())
466 }
467
468 /// The plan's product at Stripe, made the first time it is needed.
469 async fn plan_product(&self, title: &str) -> Result<String> {
470 #[derive(Deserialize)]
471 struct Product {
472 id: String,
473 #[serde(default)]
474 metadata: Option<std::collections::HashMap<String, String>>,
475 }
476 #[derive(Deserialize)]
477 struct List {
478 data: Vec<Product>,
479 }
480 let list: List = self.call(Method::Get, "/products?active=true&limit=100", None).await?;
481 let ours = |p: &Product| p.metadata.as_ref().and_then(|m| m.get("g1t")).map(String::as_str) == Some("plan");
482 if let Some(found) = list.data.into_iter().find(ours) {
483 return Ok(found.id);
484 }
485 let created: Product = self
486 .call(
487 Method::Post,
488 "/products",
489 Some(form(&[("name", format!("{title} plan")), ("metadata[g1t]", "plan".to_owned())])),
490 )
491 .await?;
492 Ok(created.id)
493 }
494
495 /// Starts the monthly plan on a saved card, at once. Fails rather than
496 /// leaving it half-started when the card's bank wants the person again;
497 /// the caller then sends them to Stripe's page.
498 pub async fn subscribe_with_card(
499 &self,
500 workspace: &str,
501 feature: &str,
502 title: &str,
503 monthly_cents: u32,
504 customer: &str,
505 payment_method: &str,
506 ) -> Result<StripeSubscription> {
507 let product = self.plan_product(title).await?;
508 let fields = [
509 ("customer", customer.to_owned()),
510 ("default_payment_method", payment_method.to_owned()),
511 ("payment_behavior", "error_if_incomplete".to_owned()),
512 ("items[0][price_data][currency]", "usd".to_owned()),
513 ("items[0][price_data][product]", product),
514 ("items[0][price_data][unit_amount]", monthly_cents.to_string()),
515 ("items[0][price_data][recurring][interval]", "month".to_owned()),
516 ("metadata[workspace]", workspace.to_owned()),
517 ("metadata[feature]", feature.to_owned()),
518 ("description", format!("{title} plan for {workspace}")),
519 ];
520 self.call(Method::Post, "/subscriptions", Some(form(&fields))).await
521 }
522
523 /// Ends a subscription now: one that never started properly.
524 pub async fn cancel_now(&self, id: &str) -> Result<StripeSubscription> {
525 self.call(Method::Delete, &format!("/subscriptions/{}", encode(id)), None).await
526 }
527
528 pub async fn subscription(&self, id: &str) -> Result<StripeSubscription> {
529 self.call(Method::Get, &format!("/subscriptions/{}", encode(id)), None)
530 .await
531 }
532
533 /// Ends a plan when its period does (`cancel` true), or takes that back.
534 pub async fn cancel_at_period_end(&self, id: &str, cancel: bool) -> Result<StripeSubscription> {
535 self.call(
536 Method::Post,
537 &format!("/subscriptions/{}", encode(id)),
538 Some(form(&[("cancel_at_period_end", cancel.to_string())])),
539 )
540 .await
541 }
542
543 pub async fn session(&self, id: &str) -> Result<Session> {
544 self.call(
545 Method::Get,
546 &format!("/checkout/sessions/{}", encode(id)),
547 None,
548 )
549 .await
550 }
551}
552
553/// Whether the processor said an id it was given does not exist, as when
554/// g1t moves to another Stripe account and ids saved from the old one stay
555/// behind.
556pub(crate) fn is_missing(error: &Error) -> bool {
557 error.to_string().contains("resource_missing")
558}
559
560pub(crate) fn is_live(key: &str) -> bool {
561 key.starts_with("sk_live_") || key.starts_with("rk_live_")
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567
568 #[test]
569 fn form_values_are_percent_encoded() {
570 assert_eq!(
571 form(&[
572 (
573 "success_url",
574 "https://g1t.sh/a/-/billing?session={ID}".to_owned()
575 ),
576 ("line_items[0][quantity]", "1".to_owned()),
577 ]),
578 "success_url=https%3A%2F%2Fg1t.sh%2Fa%2F-%2Fbilling%3Fsession%3D%7BID%7D&line_items%5B0%5D%5Bquantity%5D=1"
579 );
580 }
581
582 #[test]
583 fn test_keys_are_not_live() {
584 assert!(is_live("sk_live_abc"));
585 assert!(!is_live("sk_test_abc"));
586 assert!(!is_live(""));
587 }
588}