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

282 lines9,362 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/// A monthly plan.
31#[derive(Deserialize)]
32pub struct StripeSubscription {
33 pub id: String,
34 /// `active`, `trialing`, `past_due`, `unpaid`, `canceled`, `incomplete`…
35 pub status: String,
36 #[serde(default)]
37 pub cancel_at_period_end: bool,
38 /// Unix seconds. Older API versions carry it here…
39 #[serde(default)]
40 pub current_period_end: Option<i64>,
41 /// …newer ones on each item.
42 #[serde(default)]
43 pub items: Option<Items>,
44}
45
46#[derive(Deserialize)]
47pub struct Items {
48 pub data: Vec<Item>,
49}
50
51#[derive(Deserialize)]
52pub struct Item {
53 #[serde(default)]
54 pub current_period_end: Option<i64>,
55}
56
57impl StripeSubscription {
58 /// When the period paid for ends, in Unix seconds.
59 pub fn period_end(&self) -> Option<i64> {
60 self.current_period_end.or_else(|| {
61 self.items
62 .as_ref()
63 .and_then(|items| items.data.iter().filter_map(|item| item.current_period_end).max())
64 })
65 }
66}
67
68/// Percent-encodes a form value.
69fn encode(value: &str) -> String {
70 let mut encoded = String::with_capacity(value.len());
71 for byte in value.bytes() {
72 match byte {
73 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
74 encoded.push(byte as char);
75 }
76 _ => encoded.push_str(&format!("%{byte:02X}")),
77 }
78 }
79 encoded
80}
81
82/// `name=value` pairs as a form body.
83pub(crate) fn form(fields: &[(&str, String)]) -> String {
84 fields
85 .iter()
86 .map(|(name, value)| format!("{}={}", encode(name), encode(value)))
87 .collect::<Vec<_>>()
88 .join("&")
89}
90
91impl Stripe {
92 pub fn new(key: String) -> Self {
93 Stripe { key }
94 }
95
96 /// Whether the key is for real cards, not Stripe's test mode.
97 pub fn live(&self) -> bool {
98 is_live(&self.key)
99 }
100
101 async fn call<T: for<'a> Deserialize<'a>>(
102 &self,
103 method: Method,
104 path: &str,
105 body: Option<String>,
106 ) -> Result<T> {
107 let headers = Headers::new();
108 headers.set("authorization", &format!("Bearer {}", self.key))?;
109 if body.is_some() {
110 headers.set("content-type", "application/x-www-form-urlencoded")?;
111 }
112 let mut init = RequestInit::new();
113 init.with_method(method).with_headers(headers);
114 if let Some(body) = body {
115 init.with_body(Some(body.into()));
116 }
117 let request = Request::new_with_init(&format!("{API}{path}"), &init)?;
118 let mut response = Fetch::Request(request).send().await?;
119 if response.status_code() != 200 {
120 return Err(Error::RustError(format!(
121 "the card processor answered {}: {}",
122 response.status_code(),
123 response.text().await.unwrap_or_default()
124 )));
125 }
126 response.json().await
127 }
128
129 /// Starts a page on which `amount_cents` of credit is paid for by card.
130 /// The card is kept for the workspace, so that topping up again, by
131 /// hand or automatically, needs no retyping.
132 pub async fn start_checkout(
133 &self,
134 workspace: &str,
135 amount_cents: u32,
136 customer: Option<&str>,
137 return_url: &str,
138 ) -> Result<Session> {
139 let separator = if return_url.contains('?') { '&' } else { '?' };
140 let mut fields = vec![
141 ("mode", "payment".to_owned()),
142 // Cards only: credit is bought on the spot, and the card is kept
143 // for topping up again.
144 ("payment_method_types[0]", "card".to_owned()),
145 (
146 "success_url",
147 // Stripe fills in the payment's id.
148 format!("{return_url}{separator}session={{CHECKOUT_SESSION_ID}}"),
149 ),
150 ("cancel_url", return_url.to_owned()),
151 ("client_reference_id", workspace.to_owned()),
152 ("metadata[workspace]", workspace.to_owned()),
153 ("line_items[0][quantity]", "1".to_owned()),
154 ("line_items[0][price_data][currency]", "usd".to_owned()),
155 (
156 "line_items[0][price_data][unit_amount]",
157 amount_cents.to_string(),
158 ),
159 (
160 "line_items[0][price_data][product_data][name]",
161 format!("g1t agent credit for {workspace}"),
162 ),
163 (
164 "payment_intent_data[setup_future_usage]",
165 "off_session".to_owned(),
166 ),
167 ];
168 match customer {
169 Some(customer) => fields.push(("customer", customer.to_owned())),
170 None => fields.push(("customer_creation", "always".to_owned())),
171 }
172 self.call(Method::Post, "/checkout/sessions", Some(form(&fields)))
173 .await
174 }
175
176 /// Starts a page on which a feature's monthly plan is paid for by card.
177 pub async fn start_subscription(
178 &self,
179 workspace: &str,
180 feature: &str,
181 title: &str,
182 monthly_cents: u32,
183 customer: Option<&str>,
184 return_url: &str,
185 ) -> Result<Session> {
186 let separator = if return_url.contains('?') { '&' } else { '?' };
187 let mut fields = vec![
188 ("mode", "subscription".to_owned()),
189 ("payment_method_types[0]", "card".to_owned()),
190 (
191 "success_url",
192 format!("{return_url}{separator}session={{CHECKOUT_SESSION_ID}}"),
193 ),
194 ("cancel_url", return_url.to_owned()),
195 ("client_reference_id", workspace.to_owned()),
196 ("metadata[workspace]", workspace.to_owned()),
197 ("metadata[feature]", feature.to_owned()),
198 ("subscription_data[metadata][workspace]", workspace.to_owned()),
199 ("subscription_data[metadata][feature]", feature.to_owned()),
200 ("line_items[0][quantity]", "1".to_owned()),
201 ("line_items[0][price_data][currency]", "usd".to_owned()),
202 (
203 "line_items[0][price_data][unit_amount]",
204 monthly_cents.to_string(),
205 ),
206 (
207 "line_items[0][price_data][recurring][interval]",
208 "month".to_owned(),
209 ),
210 (
211 "line_items[0][price_data][product_data][name]",
212 format!("g1t {title} for {workspace}"),
213 ),
214 ];
215 if let Some(customer) = customer {
216 fields.push(("customer", customer.to_owned()));
217 }
218 self.call(Method::Post, "/checkout/sessions", Some(form(&fields)))
219 .await
220 }
221
222 pub async fn subscription(&self, id: &str) -> Result<StripeSubscription> {
223 self.call(Method::Get, &format!("/subscriptions/{}", encode(id)), None)
224 .await
225 }
226
227 /// Ends a plan when its period does (`cancel` true), or takes that back.
228 pub async fn cancel_at_period_end(&self, id: &str, cancel: bool) -> Result<StripeSubscription> {
229 self.call(
230 Method::Post,
231 &format!("/subscriptions/{}", encode(id)),
232 Some(form(&[("cancel_at_period_end", cancel.to_string())])),
233 )
234 .await
235 }
236
237 pub async fn session(&self, id: &str) -> Result<Session> {
238 self.call(
239 Method::Get,
240 &format!("/checkout/sessions/{}", encode(id)),
241 None,
242 )
243 .await
244 }
245}
246
247/// Whether the processor said an id it was given does not exist, as when
248/// g1t moves to another Stripe account and ids saved from the old one stay
249/// behind.
250pub(crate) fn is_missing(error: &Error) -> bool {
251 error.to_string().contains("resource_missing")
252}
253
254pub(crate) fn is_live(key: &str) -> bool {
255 key.starts_with("sk_live_") || key.starts_with("rk_live_")
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn form_values_are_percent_encoded() {
264 assert_eq!(
265 form(&[
266 (
267 "success_url",
268 "https://g1t.sh/a/-/billing?session={ID}".to_owned()
269 ),
270 ("line_items[0][quantity]", "1".to_owned()),
271 ]),
272 "success_url=https%3A%2F%2Fg1t.sh%2Fa%2F-%2Fbilling%3Fsession%3D%7BID%7D&line_items%5B0%5D%5Bquantity%5D=1"
273 );
274 }
275
276 #[test]
277 fn test_keys_are_not_live() {
278 assert!(is_live("sk_live_abc"));
279 assert!(!is_live("sk_test_abc"));
280 assert!(!is_live(""));
281 }
282}