g1t/services/billing/src/stripe.rs

453 lines15,557 bytesCodeBlame
1//! 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
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 /// For a plan's page: the subscription it started.
26 #[serde(default)]
27 pub subscription: Option<String>,
28}
29
30/// 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
54/// 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
92/// 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.
107/// 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
117pub(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
135 async fn call<T: for<'a> Deserialize<'a>>(
136 &self,
137 method: Method,
138 path: &str,
139 body: Option<String>,
140 ) -> Result<T> {
141 self.send(method, path, body, None).await
142 }
143
144 async fn send<T: for<'a> Deserialize<'a>>(
145 &self,
146 method: Method,
147 path: &str,
148 body: Option<String>,
149 idempotency_key: Option<&str>,
150 ) -> Result<T> {
151 let headers = Headers::new();
152 headers.set("authorization", &format!("Bearer {}", self.key))?;
153 if let Some(key) = idempotency_key {
154 headers.set("idempotency-key", key)?;
155 }
156 if body.is_some() {
157 headers.set("content-type", "application/x-www-form-urlencoded")?;
158 }
159 let mut init = RequestInit::new();
160 init.with_method(method).with_headers(headers);
161 if let Some(body) = body {
162 init.with_body(Some(body.into()));
163 }
164 let request = Request::new_with_init(&format!("{API}{path}"), &init)?;
165 let mut response = Fetch::Request(request).send().await?;
166 if response.status_code() != 200 {
167 return Err(Error::RustError(format!(
168 "the card processor answered {}: {}",
169 response.status_code(),
170 response.text().await.unwrap_or_default()
171 )));
172 }
173 response.json().await
174 }
175
176 /// Charges the customer's saved card, with no one there: the automatic
177 /// payment at a workspace's limit. `key` makes a retry the same charge.
178 pub async fn charge_saved_card(
179 &self,
180 customer: &str,
181 amount_cents: i64,
182 description: &str,
183 key: &str,
184 ) -> Result<PaymentIntent> {
185 #[derive(Deserialize)]
186 struct Methods {
187 data: Vec<Method_>,
188 }
189 #[derive(Deserialize)]
190 struct Method_ {
191 id: String,
192 }
193 let methods: Methods = self
194 .call(Method::Get, &format!("/payment_methods?customer={}&type=card&limit=1", encode(customer)), None)
195 .await?;
196 let Some(card) = methods.data.first() else {
197 return Err(Error::RustError("no card on file".into()));
198 };
199 let fields = [
200 ("amount", amount_cents.to_string()),
201 ("currency", "usd".to_owned()),
202 ("customer", customer.to_owned()),
203 ("payment_method", card.id.clone()),
204 ("off_session", "true".to_owned()),
205 ("confirm", "true".to_owned()),
206 ("description", description.to_owned()),
207 ];
208 self.send(Method::Post, "/payment_intents", Some(form(&fields)), Some(key)).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` of credit is paid for by card.
301 /// The card is kept for the workspace, so that topping up again, by
302 /// hand or automatically, needs no retyping.
303 pub async fn start_checkout(
304 &self,
305 workspace: &str,
306 amount_cents: u32,
307 customer: Option<&str>,
308 return_url: &str,
309 ) -> Result<Session> {
310 let separator = if return_url.contains('?') { '&' } else { '?' };
311 let mut fields = vec![
312 ("mode", "payment".to_owned()),
313 // Cards only: credit is bought on the spot, and the card is kept
314 // for topping up again.
315 ("payment_method_types[0]", "card".to_owned()),
316 (
317 "success_url",
318 // Stripe fills in the payment's id.
319 format!("{return_url}{separator}session={{CHECKOUT_SESSION_ID}}"),
320 ),
321 ("cancel_url", return_url.to_owned()),
322 ("client_reference_id", workspace.to_owned()),
323 ("metadata[workspace]", workspace.to_owned()),
324 ("line_items[0][quantity]", "1".to_owned()),
325 ("line_items[0][price_data][currency]", "usd".to_owned()),
326 (
327 "line_items[0][price_data][unit_amount]",
328 amount_cents.to_string(),
329 ),
330 (
331 "line_items[0][price_data][product_data][name]",
332 format!("g1t agent credit for {workspace}"),
333 ),
334 (
335 "payment_intent_data[setup_future_usage]",
336 "off_session".to_owned(),
337 ),
338 ];
339 match customer {
340 Some(customer) => fields.push(("customer", customer.to_owned())),
341 None => fields.push(("customer_creation", "always".to_owned())),
342 }
343 self.call(Method::Post, "/checkout/sessions", Some(form(&fields)))
344 .await
345 }
346
347 /// Starts a page on which a feature's monthly plan is paid for by card.
348 pub async fn start_subscription(
349 &self,
350 workspace: &str,
351 feature: &str,
352 title: &str,
353 monthly_cents: u32,
354 customer: Option<&str>,
355 return_url: &str,
356 ) -> Result<Session> {
357 let separator = if return_url.contains('?') { '&' } else { '?' };
358 let mut fields = vec![
359 ("mode", "subscription".to_owned()),
360 ("payment_method_types[0]", "card".to_owned()),
361 (
362 "success_url",
363 format!("{return_url}{separator}session={{CHECKOUT_SESSION_ID}}"),
364 ),
365 ("cancel_url", return_url.to_owned()),
366 ("client_reference_id", workspace.to_owned()),
367 ("metadata[workspace]", workspace.to_owned()),
368 ("metadata[feature]", feature.to_owned()),
369 ("subscription_data[metadata][workspace]", workspace.to_owned()),
370 ("subscription_data[metadata][feature]", feature.to_owned()),
371 ("line_items[0][quantity]", "1".to_owned()),
372 ("line_items[0][price_data][currency]", "usd".to_owned()),
373 (
374 "line_items[0][price_data][unit_amount]",
375 monthly_cents.to_string(),
376 ),
377 (
378 "line_items[0][price_data][recurring][interval]",
379 "month".to_owned(),
380 ),
381 (
382 "line_items[0][price_data][product_data][name]",
383 format!("g1t {title} for {workspace}"),
384 ),
385 ];
386 if let Some(customer) = customer {
387 fields.push(("customer", customer.to_owned()));
388 }
389 self.call(Method::Post, "/checkout/sessions", Some(form(&fields)))
390 .await
391 }
392
393 pub async fn subscription(&self, id: &str) -> Result<StripeSubscription> {
394 self.call(Method::Get, &format!("/subscriptions/{}", encode(id)), None)
395 .await
396 }
397
398 /// Ends a plan when its period does (`cancel` true), or takes that back.
399 pub async fn cancel_at_period_end(&self, id: &str, cancel: bool) -> Result<StripeSubscription> {
400 self.call(
401 Method::Post,
402 &format!("/subscriptions/{}", encode(id)),
403 Some(form(&[("cancel_at_period_end", cancel.to_string())])),
404 )
405 .await
406 }
407
408 pub async fn session(&self, id: &str) -> Result<Session> {
409 self.call(
410 Method::Get,
411 &format!("/checkout/sessions/{}", encode(id)),
412 None,
413 )
414 .await
415 }
416}
417
418/// Whether the processor said an id it was given does not exist, as when
419/// g1t moves to another Stripe account and ids saved from the old one stay
420/// behind.
421pub(crate) fn is_missing(error: &Error) -> bool {
422 error.to_string().contains("resource_missing")
423}
424
425pub(crate) fn is_live(key: &str) -> bool {
426 key.starts_with("sk_live_") || key.starts_with("rk_live_")
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 #[test]
434 fn form_values_are_percent_encoded() {
435 assert_eq!(
436 form(&[
437 (
438 "success_url",
439 "https://g1t.sh/a/-/billing?session={ID}".to_owned()
440 ),
441 ("line_items[0][quantity]", "1".to_owned()),
442 ]),
443 "success_url=https%3A%2F%2Fg1t.sh%2Fa%2F-%2Fbilling%3Fsession%3D%7BID%7D&line_items%5B0%5D%5Bquantity%5D=1"
444 );
445 }
446
447 #[test]
448 fn test_keys_are_not_live() {
449 assert!(is_live("sk_live_abc"));
450 assert!(!is_live("sk_test_abc"));
451 assert!(!is_live(""));
452 }
453}