pr_01m47d24b0e6n91zwymwxg0vpx/services/billing/src/stripe.rs

173 lines5,589 bytesCodeBlame
1//! The card processor, behind the two calls billing needs: start a payment
2//! page, and ask whether a payment was made. Stripe speaks form-encoded
3//! 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>,
25}
26
27/// Percent-encodes a form value.
28fn encode(value: &str) -> String {
29 let mut encoded = String::with_capacity(value.len());
30 for byte in value.bytes() {
31 match byte {
32 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
33 encoded.push(byte as char);
34 }
35 _ => encoded.push_str(&format!("%{byte:02X}")),
36 }
37 }
38 encoded
39}
40
41/// `name=value` pairs as a form body.
42pub(crate) fn form(fields: &[(&str, String)]) -> String {
43 fields
44 .iter()
45 .map(|(name, value)| format!("{}={}", encode(name), encode(value)))
46 .collect::<Vec<_>>()
47 .join("&")
48}
49
50impl Stripe {
51 pub fn new(key: String) -> Self {
52 Stripe { key }
53 }
54
55 /// Whether the key is for real cards, not Stripe's test mode.
56 pub fn live(&self) -> bool {
57 is_live(&self.key)
58 }
59
60 async fn call<T: for<'a> Deserialize<'a>>(
61 &self,
62 method: Method,
63 path: &str,
64 body: Option<String>,
65 ) -> Result<T> {
66 let headers = Headers::new();
67 headers.set("authorization", &format!("Bearer {}", self.key))?;
68 if body.is_some() {
69 headers.set("content-type", "application/x-www-form-urlencoded")?;
70 }
71 let mut init = RequestInit::new();
72 init.with_method(method).with_headers(headers);
73 if let Some(body) = body {
74 init.with_body(Some(body.into()));
75 }
76 let request = Request::new_with_init(&format!("{API}{path}"), &init)?;
77 let mut response = Fetch::Request(request).send().await?;
78 if response.status_code() != 200 {
79 return Err(Error::RustError(format!(
80 "the card processor answered {}: {}",
81 response.status_code(),
82 response.text().await.unwrap_or_default()
83 )));
84 }
85 response.json().await
86 }
87
88 /// Starts a page on which `amount_cents` of credit is paid for by card.
89 /// The card is kept for the workspace, so that topping up again, by
90 /// hand or automatically, needs no retyping.
91 pub async fn start_checkout(
92 &self,
93 workspace: &str,
94 amount_cents: u32,
95 customer: Option<&str>,
96 return_url: &str,
97 ) -> Result<Session> {
98 let separator = if return_url.contains('?') { '&' } else { '?' };
99 let mut fields = vec![
100 ("mode", "payment".to_owned()),
101 // Cards only: credit is bought on the spot, and the card is kept
102 // for topping up again.
103 ("payment_method_types[0]", "card".to_owned()),
104 (
105 "success_url",
106 // Stripe fills in the payment's id.
107 format!("{return_url}{separator}session={{CHECKOUT_SESSION_ID}}"),
108 ),
109 ("cancel_url", return_url.to_owned()),
110 ("client_reference_id", workspace.to_owned()),
111 ("metadata[workspace]", workspace.to_owned()),
112 ("line_items[0][quantity]", "1".to_owned()),
113 ("line_items[0][price_data][currency]", "usd".to_owned()),
114 (
115 "line_items[0][price_data][unit_amount]",
116 amount_cents.to_string(),
117 ),
118 (
119 "line_items[0][price_data][product_data][name]",
120 format!("g1t agent credit for {workspace}"),
121 ),
122 (
123 "payment_intent_data[setup_future_usage]",
124 "off_session".to_owned(),
125 ),
126 ];
127 match customer {
128 Some(customer) => fields.push(("customer", customer.to_owned())),
129 None => fields.push(("customer_creation", "always".to_owned())),
130 }
131 self.call(Method::Post, "/checkout/sessions", Some(form(&fields)))
132 .await
133 }
134
135 pub async fn session(&self, id: &str) -> Result<Session> {
136 self.call(
137 Method::Get,
138 &format!("/checkout/sessions/{}", encode(id)),
139 None,
140 )
141 .await
142 }
143}
144
145pub(crate) fn is_live(key: &str) -> bool {
146 key.starts_with("sk_live_") || key.starts_with("rk_live_")
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn form_values_are_percent_encoded() {
155 assert_eq!(
156 form(&[
157 (
158 "success_url",
159 "https://g1t.sh/a/-/billing?session={ID}".to_owned()
160 ),
161 ("line_items[0][quantity]", "1".to_owned()),
162 ]),
163 "success_url=https%3A%2F%2Fg1t.sh%2Fa%2F-%2Fbilling%3Fsession%3D%7BID%7D&line_items%5B0%5D%5Bquantity%5D=1"
164 );
165 }
166
167 #[test]
168 fn test_keys_are_not_live() {
169 assert!(is_live("sk_live_abc"));
170 assert!(!is_live("sk_test_abc"));
171 assert!(!is_live(""));
172 }
173}