g1t/services/billing/src/stripe.rs

584 lines21,873 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 async fn call<T: for<'a> Deserialize<'a>>(
167 &self,
168 method: Method,
169 path: &str,
170 body: Option<String>,
171 ) -> Result<T> {
172 self.send(method, path, body, None).await
173 }
174
175 async fn send<T: for<'a> Deserialize<'a>>(
176 &self,
177 method: Method,
178 path: &str,
179 body: Option<String>,
180 idempotency_key: Option<&str>,
181 ) -> Result<T> {
182 let headers = Headers::new();
183 headers.set("authorization", &format!("Bearer {}", self.key))?;
184 if let Some(key) = idempotency_key {
185 headers.set("idempotency-key", key)?;
186 }
187 if body.is_some() {
188 headers.set("content-type", "application/x-www-form-urlencoded")?;
189 }
190 let mut init = RequestInit::new();
191 init.with_method(method).with_headers(headers);
192 if let Some(body) = body {
193 init.with_body(Some(body.into()));
194 }
195 let request = Request::new_with_init(&format!("{API}{path}"), &init)?;
196 let mut response = Fetch::Request(request).send().await?;
197 if response.status_code() != 200 {
198 return Err(Error::RustError(format!(
199 "the card processor answered {}: {}",
200 response.status_code(),
201 response.text().await.unwrap_or_default()
202 )));
203 }
204 response.json().await
205 }
206
207 /// A customer for a workspace that has none yet.
208 pub async fn create_customer(&self, workspace: &str) -> Result<String> {
209 #[derive(Deserialize)]
210 struct Customer {
211 id: String,
212 }
213 let fields = [
214 ("name", workspace.to_owned()),
215 ("metadata[workspace]", workspace.to_owned()),
216 ];
217 let customer: Customer = self.call(Method::Post, "/customers", Some(form(&fields))).await?;
218 Ok(customer.id)
219 }
220
221 /// A session on Stripe's hosted billing page (the customer portal) for
222 /// the customer, coming back to `return_url`.
223 pub async fn portal_session(&self, customer: &str, return_url: &str) -> Result<String> {
224 #[derive(Deserialize)]
225 struct Portal {
226 url: String,
227 }
228 let configuration = self.portal_configuration().await?;
229 let fields = [
230 ("customer", customer.to_owned()),
231 ("return_url", return_url.to_owned()),
232 ("configuration", configuration.id),
233 ];
234 let portal: Portal = self.call(Method::Post, "/billing_portal/sessions", Some(form(&fields))).await?;
235 Ok(portal.url)
236 }
237
238 /// g1t's billing page settings at Stripe, made the first time they are
239 /// needed: cards, invoices, billing details, and a sign-in page.
240 pub async fn portal_configuration(&self) -> Result<PortalConfiguration> {
241 #[derive(Deserialize)]
242 struct List {
243 data: Vec<PortalConfiguration>,
244 }
245 let list: List = self
246 .call(Method::Get, "/billing_portal/configurations?active=true&limit=20", None)
247 .await?;
248 if let Some(existing) = list
249 .data
250 .into_iter()
251 .find(|c| c.metadata.as_ref().and_then(|m| m.get("g1t")).is_some())
252 {
253 return Ok(existing);
254 }
255 let fields = [
256 ("business_profile[headline]", "g1t billing: your card, invoices and billing details".to_owned()),
257 ("features[payment_method_update][enabled]", "true".to_owned()),
258 ("features[invoice_history][enabled]", "true".to_owned()),
259 ("features[customer_update][enabled]", "true".to_owned()),
260 ("features[customer_update][allowed_updates][0]", "email".to_owned()),
261 ("features[customer_update][allowed_updates][1]", "address".to_owned()),
262 ("features[customer_update][allowed_updates][2]", "name".to_owned()),
263 ("features[customer_update][allowed_updates][3]", "tax_id".to_owned()),
264 ("login_page[enabled]", "true".to_owned()),
265 ("metadata[g1t]", "billing".to_owned()),
266 ];
267 self.call(Method::Post, "/billing_portal/configurations", Some(form(&fields))).await
268 }
269
270 /// The customer's email at Stripe, if they gave one.
271 pub async fn customer_email(&self, customer: &str) -> Result<Option<String>> {
272 #[derive(Deserialize)]
273 struct Customer {
274 email: Option<String>,
275 }
276 let found: Customer = self.call(Method::Get, &format!("/customers/{}", encode(customer)), None).await?;
277 Ok(found.email)
278 }
279
280 /// The customer's card, if one is saved.
281 pub async fn card(&self, customer: &str) -> Result<Option<SavedCard>> {
282 #[derive(Deserialize)]
283 struct Methods {
284 data: Vec<Method_>,
285 }
286 #[derive(Deserialize)]
287 struct Method_ {
288 card: Option<SavedCard>,
289 }
290 let methods: Methods = self
291 .call(Method::Get, &format!("/payment_methods?customer={}&type=card&limit=1", encode(customer)), None)
292 .await?;
293 Ok(methods.data.into_iter().next().and_then(|m| m.card))
294 }
295
296 /// Starts a page on which `amount_cents` is paid in advance: by card,
297 /// with 3-D Secure asked for wherever the card supports it, the card
298 /// kept for later charges; or, with `bank_transfer` and a customer, by
299 /// bank transfer to the account details Stripe gives, counted when the
300 /// money arrives.
301 pub async fn start_checkout(
302 &self,
303 workspace: &str,
304 amount_cents: u32,
305 customer: Option<&str>,
306 return_url: &str,
307 bank_transfer: bool,
308 ) -> Result<Session> {
309 let separator = if return_url.contains('?') { '&' } else { '?' };
310 let mut fields = vec![("mode", "payment".to_owned())];
311 if bank_transfer {
312 fields.extend([
313 ("payment_method_types[0]", "customer_balance".to_owned()),
314 ("payment_method_options[customer_balance][funding_type]", "bank_transfer".to_owned()),
315 ("payment_method_options[customer_balance][bank_transfer][type]", "us_bank_transfer".to_owned()),
316 ]);
317 } else {
318 fields.extend([
319 ("payment_method_types[0]", "card".to_owned()),
320 ("payment_method_options[card][request_three_d_secure]", "any".to_owned()),
321 ("payment_intent_data[setup_future_usage]", "off_session".to_owned()),
322 ]);
323 }
324 fields.extend([
325 (
326 "success_url",
327 // Stripe fills in the payment's id.
328 format!("{return_url}{separator}session={{CHECKOUT_SESSION_ID}}"),
329 ),
330 ("cancel_url", return_url.to_owned()),
331 ("client_reference_id", workspace.to_owned()),
332 ("metadata[workspace]", workspace.to_owned()),
333 ("line_items[0][quantity]", "1".to_owned()),
334 ("line_items[0][price_data][currency]", "usd".to_owned()),
335 (
336 "line_items[0][price_data][unit_amount]",
337 amount_cents.to_string(),
338 ),
339 (
340 "line_items[0][price_data][product_data][name]",
341 format!("g1t usage paid in advance for {workspace}"),
342 ),
343 ]);
344 match customer {
345 Some(customer) => fields.push(("customer", customer.to_owned())),
346 None => fields.push(("customer_creation", "always".to_owned())),
347 }
348 self.call(Method::Post, "/checkout/sessions", Some(form(&fields)))
349 .await
350 }
351
352 /// Starts a page on which a feature's monthly plan is paid for by card.
353 pub async fn start_subscription(
354 &self,
355 workspace: &str,
356 feature: &str,
357 title: &str,
358 monthly_cents: u32,
359 customer: Option<&str>,
360 return_url: &str,
361 ) -> Result<Session> {
362 let separator = if return_url.contains('?') { '&' } else { '?' };
363 let mut fields = vec![
364 ("mode", "subscription".to_owned()),
365 ("payment_method_types[0]", "card".to_owned()),
366 (
367 "success_url",
368 format!("{return_url}{separator}session={{CHECKOUT_SESSION_ID}}"),
369 ),
370 ("cancel_url", return_url.to_owned()),
371 ("client_reference_id", workspace.to_owned()),
372 ("metadata[workspace]", workspace.to_owned()),
373 ("metadata[feature]", feature.to_owned()),
374 ("subscription_data[metadata][workspace]", workspace.to_owned()),
375 ("subscription_data[metadata][feature]", feature.to_owned()),
376 ("line_items[0][quantity]", "1".to_owned()),
377 ("line_items[0][price_data][currency]", "usd".to_owned()),
378 (
379 "line_items[0][price_data][unit_amount]",
380 monthly_cents.to_string(),
381 ),
382 (
383 "line_items[0][price_data][recurring][interval]",
384 "month".to_owned(),
385 ),
386 (
387 "line_items[0][price_data][product_data][name]",
388 format!("g1t {title} for {workspace}"),
389 ),
390 ];
391 if let Some(customer) = customer {
392 fields.push(("customer", customer.to_owned()));
393 }
394 self.call(Method::Post, "/checkout/sessions", Some(form(&fields)))
395 .await
396 }
397
398 /// Starts a page that saves and verifies a card, with 3-D Secure asked
399 /// for wherever the card supports it. Nothing is charged: the card's
400 /// bank sees at most a $0 or $1 authorization that is never captured.
401 pub async fn start_card_check(&self, workspace: &str, customer: &str, return_url: &str) -> Result<Session> {
402 let separator = if return_url.contains('?') { '&' } else { '?' };
403 let fields = [
404 ("mode", "setup".to_owned()),
405 ("customer", customer.to_owned()),
406 ("payment_method_types[0]", "card".to_owned()),
407 ("payment_method_options[card][request_three_d_secure]", "any".to_owned()),
408 ("success_url", format!("{return_url}{separator}card_check={{CHECKOUT_SESSION_ID}}")),
409 ("cancel_url", return_url.to_owned()),
410 ("client_reference_id", workspace.to_owned()),
411 ("metadata[workspace]", workspace.to_owned()),
412 ("metadata[purpose]", "card_check".to_owned()),
413 ("setup_intent_data[metadata][workspace]", workspace.to_owned()),
414 ("setup_intent_data[description]", format!("Card check for g1t workspace {workspace}; never charged")),
415 ];
416 self.call(Method::Post, "/checkout/sessions", Some(form(&fields))).await
417 }
418
419 /// What a card check's setup found, once it succeeded.
420 pub async fn checked_card(&self, setup_intent: &str) -> Result<Option<CheckedCard>> {
421 #[derive(Deserialize)]
422 struct Setup {
423 status: String,
424 payment_method: Option<String>,
425 }
426 #[derive(Deserialize)]
427 struct Card {
428 fingerprint: Option<String>,
429 brand: Option<String>,
430 last4: Option<String>,
431 funding: Option<String>,
432 country: Option<String>,
433 }
434 #[derive(Deserialize)]
435 struct PaymentMethod {
436 card: Option<Card>,
437 }
438 let setup: Setup = self.call(Method::Get, &format!("/setup_intents/{}", encode(setup_intent)), None).await?;
439 let (true, Some(method)) = (setup.status == "succeeded", setup.payment_method) else { return Ok(None) };
440 let found: PaymentMethod = self.call(Method::Get, &format!("/payment_methods/{}", encode(&method)), None).await?;
441 let card = found.card;
442 Ok(Some(CheckedCard {
443 payment_method: method,
444 fingerprint: card.as_ref().and_then(|c| c.fingerprint.clone()),
445 brand: card.as_ref().and_then(|c| c.brand.clone()),
446 last4: card.as_ref().and_then(|c| c.last4.clone()),
447 funding: card.as_ref().and_then(|c| c.funding.clone()),
448 country: card.as_ref().and_then(|c| c.country.clone()),
449 }))
450 }
451
452 /// Makes `payment_method` the card the customer's invoices are charged to.
453 pub async fn set_default_card(&self, customer: &str, payment_method: &str) -> Result<()> {
454 let _: serde_json::Value = self
455 .call(
456 Method::Post,
457 &format!("/customers/{}", encode(customer)),
458 Some(form(&[("invoice_settings[default_payment_method]", payment_method.to_owned())])),
459 )
460 .await?;
461 Ok(())
462 }
463
464 /// The plan's product at Stripe, made the first time it is needed.
465 async fn plan_product(&self, title: &str) -> Result<String> {
466 #[derive(Deserialize)]
467 struct Product {
468 id: String,
469 #[serde(default)]
470 metadata: Option<std::collections::HashMap<String, String>>,
471 }
472 #[derive(Deserialize)]
473 struct List {
474 data: Vec<Product>,
475 }
476 let list: List = self.call(Method::Get, "/products?active=true&limit=100", None).await?;
477 let ours = |p: &Product| p.metadata.as_ref().and_then(|m| m.get("g1t")).map(String::as_str) == Some("plan");
478 if let Some(found) = list.data.into_iter().find(ours) {
479 return Ok(found.id);
480 }
481 let created: Product = self
482 .call(
483 Method::Post,
484 "/products",
485 Some(form(&[("name", format!("{title} plan")), ("metadata[g1t]", "plan".to_owned())])),
486 )
487 .await?;
488 Ok(created.id)
489 }
490
491 /// Starts the monthly plan on a saved card, at once. Fails rather than
492 /// leaving it half-started when the card's bank wants the person again;
493 /// the caller then sends them to Stripe's page.
494 pub async fn subscribe_with_card(
495 &self,
496 workspace: &str,
497 feature: &str,
498 title: &str,
499 monthly_cents: u32,
500 customer: &str,
501 payment_method: &str,
502 ) -> Result<StripeSubscription> {
503 let product = self.plan_product(title).await?;
504 let fields = [
505 ("customer", customer.to_owned()),
506 ("default_payment_method", payment_method.to_owned()),
507 ("payment_behavior", "error_if_incomplete".to_owned()),
508 ("items[0][price_data][currency]", "usd".to_owned()),
509 ("items[0][price_data][product]", product),
510 ("items[0][price_data][unit_amount]", monthly_cents.to_string()),
511 ("items[0][price_data][recurring][interval]", "month".to_owned()),
512 ("metadata[workspace]", workspace.to_owned()),
513 ("metadata[feature]", feature.to_owned()),
514 ("description", format!("{title} plan for {workspace}")),
515 ];
516 self.call(Method::Post, "/subscriptions", Some(form(&fields))).await
517 }
518
519 /// Ends a subscription now: one that never started properly.
520 pub async fn cancel_now(&self, id: &str) -> Result<StripeSubscription> {
521 self.call(Method::Delete, &format!("/subscriptions/{}", encode(id)), None).await
522 }
523
524 pub async fn subscription(&self, id: &str) -> Result<StripeSubscription> {
525 self.call(Method::Get, &format!("/subscriptions/{}", encode(id)), None)
526 .await
527 }
528
529 /// Ends a plan when its period does (`cancel` true), or takes that back.
530 pub async fn cancel_at_period_end(&self, id: &str, cancel: bool) -> Result<StripeSubscription> {
531 self.call(
532 Method::Post,
533 &format!("/subscriptions/{}", encode(id)),
534 Some(form(&[("cancel_at_period_end", cancel.to_string())])),
535 )
536 .await
537 }
538
539 pub async fn session(&self, id: &str) -> Result<Session> {
540 self.call(
541 Method::Get,
542 &format!("/checkout/sessions/{}", encode(id)),
543 None,
544 )
545 .await
546 }
547}
548
549/// Whether the processor said an id it was given does not exist, as when
550/// g1t moves to another Stripe account and ids saved from the old one stay
551/// behind.
552pub(crate) fn is_missing(error: &Error) -> bool {
553 error.to_string().contains("resource_missing")
554}
555
556pub(crate) fn is_live(key: &str) -> bool {
557 key.starts_with("sk_live_") || key.starts_with("rk_live_")
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563
564 #[test]
565 fn form_values_are_percent_encoded() {
566 assert_eq!(
567 form(&[
568 (
569 "success_url",
570 "https://g1t.sh/a/-/billing?session={ID}".to_owned()
571 ),
572 ("line_items[0][quantity]", "1".to_owned()),
573 ]),
574 "success_url=https%3A%2F%2Fg1t.sh%2Fa%2F-%2Fbilling%3Fsession%3D%7BID%7D&line_items%5B0%5D%5Bquantity%5D=1"
575 );
576 }
577
578 #[test]
579 fn test_keys_are_not_live() {
580 assert!(is_live("sk_live_abc"));
581 assert!(!is_live("sk_test_abc"));
582 assert!(!is_live(""));
583 }
584}