Skip to content

g1t/services/billing/src/stripe.rs

881 lines37,494 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//!
10//! Every request names the API version it is written for
11//! (`STRIPE_VERSION`). Without it Stripe answers at the account's default,
12//! which for an account made today is a newer version than this code reads:
13//! invoices no longer carry `subscription`, charges no longer carry
14//! `invoice`, and parameters this code sends can be refused. A failure is
15//! never shown raw: `friendly` turns it into a sentence for the page.
16
17use serde::Deserialize;
18use worker::{Error, Fetch, Headers, Method, Request, RequestInit, Result};
19
20const API: &str = "https://api.stripe.com/v1";
21
22/// The Stripe API version billing is written against. Changing it is a
23/// code change: read Stripe's upgrade notes for every field billing reads.
24pub(crate) const STRIPE_VERSION: &str = "2025-02-24.acacia";
25
26/// What a Stripe failure says, for the person on the page: Stripe's own
27/// message when it gave one (never the request or any key), else that it
28/// could not be reached.
29pub(crate) fn friendly(error: &Error) -> String {
30 let text = error.to_string();
31 let message = text
32 .split_once(": ")
33 .and_then(|(_, body)| serde_json::from_str::<serde_json::Value>(body).ok())
34 .and_then(|body| body["error"]["message"].as_str().map(str::to_owned));
35 match message {
36 Some(message) => format!("Stripe refused it: {}", message.trim_end_matches('.').to_owned() + "."),
37 None if text.contains("the card processor answered") => "Stripe refused it. Try again in a minute; if it keeps happening, write to support@g1t.sh.".to_owned(),
38 None => "Stripe could not be reached. Try again in a minute.".to_owned(),
39 }
40}
41
42/// Whether Stripe declined a card (as opposed to refusing the request).
43pub(crate) fn is_card_error(error: &Error) -> bool {
44 let text = error.to_string();
45 text.contains("\"card_error\"") || text.contains("authentication_required")
46}
47
48pub struct Stripe {
49 key: String,
50}
51
52/// A payment page, and the payment made through it.
53#[derive(Deserialize)]
54pub struct Session {
55 pub id: String,
56 /// Where to send the person. Absent once the page has been used.
57 pub url: Option<String>,
58 /// `paid` once the money has been taken.
59 pub payment_status: String,
60 /// What was paid, in cents.
61 pub amount_total: Option<u32>,
62 pub customer: Option<String>,
63 /// For a plan's page: the subscription it started.
64 #[serde(default)]
65 pub subscription: Option<String>,
66 /// For a card check's page: the setup that saved and verified the card.
67 #[serde(default)]
68 pub setup_intent: Option<String>,
69}
70
71/// A card saved and verified: what a card check found.
72#[derive(Debug, Deserialize)]
73pub struct CheckedCard {
74 pub payment_method: String,
75 pub fingerprint: Option<String>,
76 pub brand: Option<String>,
77 pub last4: Option<String>,
78 /// `credit`, `debit`, `prepaid` or `unknown`.
79 pub funding: Option<String>,
80 pub country: Option<String>,
81}
82
83/// g1t's settings for Stripe's hosted billing page.
84#[derive(Debug, Deserialize)]
85pub struct PortalConfiguration {
86 pub id: String,
87 #[serde(default)]
88 pub login_page: Option<LoginPage>,
89 #[serde(default)]
90 pub metadata: Option<std::collections::HashMap<String, String>>,
91}
92
93#[derive(Debug, Deserialize)]
94pub struct LoginPage {
95 pub url: Option<String>,
96}
97
98/// A monthly plan.
99#[derive(Deserialize)]
100pub struct StripeSubscription {
101 pub id: String,
102 /// `active`, `trialing`, `past_due`, `unpaid`, `canceled`, `incomplete`…
103 pub status: String,
104 #[serde(default)]
105 pub cancel_at_period_end: bool,
106 /// Unix seconds. Older API versions carry it here…
107 #[serde(default)]
108 pub current_period_end: Option<i64>,
109 /// …newer ones on each item.
110 #[serde(default)]
111 pub items: Option<Items>,
112}
113
114#[derive(Deserialize)]
115pub struct Items {
116 pub data: Vec<Item>,
117}
118
119#[derive(Deserialize)]
120pub struct Item {
121 #[serde(default)]
122 pub current_period_end: Option<i64>,
123}
124
125impl StripeSubscription {
126 /// When the period paid for ends, in Unix seconds.
127 pub fn period_end(&self) -> Option<i64> {
128 self.current_period_end.or_else(|| {
129 self.items
130 .as_ref()
131 .and_then(|items| items.data.iter().filter_map(|item| item.current_period_end).max())
132 })
133 }
134}
135
136/// Percent-encodes a form value.
137fn encode(value: &str) -> String {
138 let mut encoded = String::with_capacity(value.len());
139 for byte in value.bytes() {
140 match byte {
141 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
142 encoded.push(byte as char);
143 }
144 _ => encoded.push_str(&format!("%{byte:02X}")),
145 }
146 }
147 encoded
148}
149
150/// `name=value` pairs as a form body.
151pub(crate) fn form(fields: &[(&str, String)]) -> String {
152 fields
153 .iter()
154 .map(|(name, value)| format!("{}={}", encode(name), encode(value)))
155 .collect::<Vec<_>>()
156 .join("&")
157}
158
159/// The idempotency key for starting a plan on a saved card: the same
160/// workspace, plan and card within the same ten minutes is one subscription,
161/// however many times it is asked for (a double click, two tabs), so a
162/// workspace is never billed twice for one plan. A different card is a new
163/// attempt, as Stripe refuses a key reused with other fields.
164pub(crate) fn plan_key(workspace: &str, feature: &str, payment_method: &str, now_ms: u64) -> String {
165 format!("plan/{workspace}/{feature}/{payment_method}/{}", now_ms / 600_000)
166}
167
168impl Stripe {
169 pub fn new(key: String) -> Self {
170 Stripe { key }
171 }
172
173 /// Whether the key is for real cards, not Stripe's test mode.
174 pub fn live(&self) -> bool {
175 is_live(&self.key)
176 }
177
178 /// A GET of any Stripe resource, for the webhook handlers.
179 pub(crate) async fn get<T: for<'a> Deserialize<'a>>(&self, path: &str) -> Result<T> {
180 self.call(Method::Get, path, None).await
181 }
182
183 /// A DELETE of any Stripe resource.
184 pub(crate) async fn delete<T: for<'a> Deserialize<'a>>(&self, path: &str) -> Result<T> {
185 self.call(Method::Delete, path, None).await
186 }
187
188 /// A form POST to any Stripe resource.
189 pub(crate) async fn post<T: for<'a> Deserialize<'a>>(&self, path: &str, fields: &[(&str, String)]) -> Result<T> {
190 self.call(Method::Post, path, Some(form(fields))).await
191 }
192
193 /// A form POST that Stripe does at most once for `key`, however often
194 /// it is sent.
195 pub(crate) async fn post_idempotent<T: for<'a> Deserialize<'a>>(
196 &self,
197 path: &str,
198 fields: &[(&str, String)],
199 key: &str,
200 ) -> Result<T> {
201 self.send(Method::Post, path, Some(form(fields)), Some(key)).await
202 }
203
204 async fn call<T: for<'a> Deserialize<'a>>(
205 &self,
206 method: Method,
207 path: &str,
208 body: Option<String>,
209 ) -> Result<T> {
210 self.send(method, path, body, None).await
211 }
212
213 async fn send<T: for<'a> Deserialize<'a>>(
214 &self,
215 method: Method,
216 path: &str,
217 body: Option<String>,
218 idempotency_key: Option<&str>,
219 ) -> Result<T> {
220 let headers = Headers::new();
221 headers.set("authorization", &format!("Bearer {}", self.key))?;
222 headers.set("stripe-version", STRIPE_VERSION)?;
223 if let Some(key) = idempotency_key {
224 headers.set("idempotency-key", key)?;
225 }
226 if body.is_some() {
227 headers.set("content-type", "application/x-www-form-urlencoded")?;
228 }
229 let mut init = RequestInit::new();
230 init.with_method(method).with_headers(headers);
231 if let Some(body) = body {
232 init.with_body(Some(body.into()));
233 }
234 let request = Request::new_with_init(&format!("{API}{path}"), &init)?;
235 let mut response = Fetch::Request(request).send().await?;
236 if response.status_code() != 200 {
237 return Err(Error::RustError(format!(
238 "the card processor answered {}: {}",
239 response.status_code(),
240 response.text().await.unwrap_or_default()
241 )));
242 }
243 response.json().await
244 }
245
246 /// A customer for a workspace that has none yet.
247 pub async fn create_customer(&self, workspace: &str) -> Result<String> {
248 #[derive(Deserialize)]
249 struct Customer {
250 id: String,
251 }
252 let fields = [
253 ("name", workspace.to_owned()),
254 ("metadata[workspace]", workspace.to_owned()),
255 ];
256 let customer: Customer = self.call(Method::Post, "/customers", Some(form(&fields))).await?;
257 Ok(customer.id)
258 }
259
260 /// A session on Stripe's hosted billing page (the customer portal) for
261 /// the customer, coming back to `return_url`.
262 pub async fn portal_session(&self, customer: &str, return_url: &str) -> Result<String> {
263 #[derive(Deserialize)]
264 struct Portal {
265 url: String,
266 }
267 let configuration = self.portal_configuration().await?;
268 let fields = [
269 ("customer", customer.to_owned()),
270 ("return_url", return_url.to_owned()),
271 ("configuration", configuration.id),
272 ];
273 let portal: Portal = self.call(Method::Post, "/billing_portal/sessions", Some(form(&fields))).await?;
274 Ok(portal.url)
275 }
276
277 /// g1t's billing page settings at Stripe, made the first time they are
278 /// needed: cards, invoices, billing details, and a sign-in page.
279 pub async fn portal_configuration(&self) -> Result<PortalConfiguration> {
280 #[derive(Deserialize)]
281 struct List {
282 data: Vec<PortalConfiguration>,
283 }
284 let list: List = self
285 .call(Method::Get, "/billing_portal/configurations?active=true&limit=20", None)
286 .await?;
287 if let Some(existing) = list
288 .data
289 .into_iter()
290 .find(|c| c.metadata.as_ref().and_then(|m| m.get("g1t")).is_some())
291 {
292 return Ok(existing);
293 }
294 let fields = [
295 ("business_profile[headline]", "g1t billing: your card, invoices and billing details".to_owned()),
296 ("features[payment_method_update][enabled]", "true".to_owned()),
297 ("features[invoice_history][enabled]", "true".to_owned()),
298 ("features[customer_update][enabled]", "true".to_owned()),
299 ("features[customer_update][allowed_updates][0]", "email".to_owned()),
300 ("features[customer_update][allowed_updates][1]", "address".to_owned()),
301 ("features[customer_update][allowed_updates][2]", "name".to_owned()),
302 ("features[customer_update][allowed_updates][3]", "tax_id".to_owned()),
303 ("login_page[enabled]", "true".to_owned()),
304 ("metadata[g1t]", "billing".to_owned()),
305 ];
306 self.call(Method::Post, "/billing_portal/configurations", Some(form(&fields))).await
307 }
308
309 /// The customer's email at Stripe, if they gave one.
310 pub async fn customer_email(&self, customer: &str) -> Result<Option<String>> {
311 #[derive(Deserialize)]
312 struct Customer {
313 email: Option<String>,
314 }
315 let found: Customer = self.call(Method::Get, &format!("/customers/{}", encode(customer)), None).await?;
316 Ok(found.email)
317 }
318
319 /// Starts a page on which `amount_cents` is paid in advance: by card,
320 /// with 3-D Secure asked for wherever the card supports it, the card
321 /// kept for later charges; or, with `bank_transfer` and a customer, by
322 /// bank transfer to the account details Stripe gives, counted when the
323 /// money arrives.
324 pub async fn start_checkout(
325 &self,
326 workspace: &str,
327 amount_cents: u32,
328 customer: Option<&str>,
329 return_url: &str,
330 bank_transfer: bool,
331 ) -> Result<Session> {
332 let fields = prepay_fields(workspace, amount_cents, customer, return_url, bank_transfer);
333 let key = page_key("prepay", workspace, &format!("{amount_cents}/{bank_transfer}"), g1t_kit::now_ms());
334 self.send(Method::Post, "/checkout/sessions", Some(form(&fields)), Some(&key)).await
335 }
336
337 /// Starts a page on which a feature's monthly plan is paid for by card.
338 pub async fn start_subscription(
339 &self,
340 workspace: &str,
341 feature: &str,
342 title: &str,
343 monthly_cents: u32,
344 customer: Option<&str>,
345 return_url: &str,
346 ) -> Result<Session> {
347 let fields = subscription_fields(workspace, feature, title, monthly_cents, customer, return_url);
348 let key = page_key("plan", workspace, &format!("{feature}/{monthly_cents}/{}", customer.unwrap_or("new")), g1t_kit::now_ms());
349 self.send(Method::Post, "/checkout/sessions", Some(form(&fields)), Some(&key)).await
350 }
351
352 /// Starts a page that saves and verifies a card, with 3-D Secure asked
353 /// for wherever the card supports it. Nothing is charged: the card's
354 /// bank sees at most a $0 or $1 authorization that is never captured.
355 pub async fn start_card_check(&self, workspace: &str, customer: &str, return_url: &str) -> Result<Session> {
356 let fields = card_check_fields(workspace, customer, return_url);
357 let key = page_key("card_check", workspace, customer, g1t_kit::now_ms());
358 self.send(Method::Post, "/checkout/sessions", Some(form(&fields)), Some(&key)).await
359 }
360
361 /// Starts a page on which AI credit is bought: one payment by card, the
362 /// card fee as its own line, the card kept for auto-reload.
363 pub async fn start_credit_checkout(&self, purchase: &CreditPurchase<'_>) -> Result<Session> {
364 let fields = credit_fields(purchase);
365 let key = page_key(
366 "ai_credit",
367 purchase.workspace,
368 &format!("{}/{}/{}", purchase.credit_cents, purchase.fee_cents, purchase.customer.unwrap_or("new")),
369 g1t_kit::now_ms(),
370 );
371 self.send(Method::Post, "/checkout/sessions", Some(form(&fields)), Some(&key)).await
372 }
373
374 /// The customer's default payment method: the one its invoices are
375 /// charged to, else its newest card. None when it has none.
376 pub async fn default_payment_method(&self, customer: &str) -> Result<Option<SavedMethod>> {
377 let found: serde_json::Value = self
378 .call(
379 Method::Get,
380 &format!("/customers/{}?expand[]=invoice_settings.default_payment_method", encode(customer)),
381 None,
382 )
383 .await?;
384 if let Some(method) = SavedMethod::from_json(&found["invoice_settings"]["default_payment_method"]) {
385 return Ok(Some(method));
386 }
387 let list: serde_json::Value = self
388 .call(Method::Get, &format!("/payment_methods?customer={}&type=card&limit=1", encode(customer)), None)
389 .await?;
390 Ok(list["data"].as_array().and_then(|data| data.first()).and_then(SavedMethod::from_json))
391 }
392
393 /// The customer as Stripe keeps it, with its tax ids.
394 pub async fn customer(&self, customer: &str) -> Result<serde_json::Value> {
395 self.call(Method::Get, &format!("/customers/{}?expand[]=tax_ids", encode(customer)), None).await
396 }
397
398 /// The customer's invoices, newest first.
399 pub async fn invoices(&self, customer: &str) -> Result<Vec<serde_json::Value>> {
400 let list: serde_json::Value = self.call(Method::Get, &format!("/invoices?customer={}&limit=24", encode(customer)), None).await?;
401 Ok(list["data"].as_array().cloned().unwrap_or_default())
402 }
403
404 /// Charges a saved payment method now, with nobody there: auto-reload.
405 /// Done at most once for `key`, however often it is sent.
406 pub async fn charge_saved(&self, charge: &SavedCharge<'_>) -> Result<serde_json::Value> {
407 self.send(Method::Post, "/payment_intents", Some(form(&saved_charge_fields(charge))), Some(charge.key)).await
408 }
409}
410
411/// An AI credit purchase, as its payment page needs it.
412pub struct CreditPurchase<'a> {
413 pub workspace: &'a str,
414 pub credit_cents: u32,
415 pub fee_cents: u32,
416 pub customer: Option<&'a str>,
417 pub return_url: &'a str,
418}
419
420/// A charge to a saved card with nobody there.
421pub struct SavedCharge<'a> {
422 pub workspace: &'a str,
423 pub customer: &'a str,
424 pub payment_method: &'a str,
425 pub credit_cents: u32,
426 pub fee_cents: u32,
427 pub key: &'a str,
428}
429
430/// A saved way to pay, as far as it is safe to show.
431#[derive(Clone, Debug, PartialEq)]
432pub struct SavedMethod {
433 pub id: String,
434 pub kind: String,
435 pub brand: Option<String>,
436 pub last4: Option<String>,
437 pub exp_month: Option<u32>,
438 pub exp_year: Option<u32>,
439}
440
441impl SavedMethod {
442 fn from_json(method: &serde_json::Value) -> Option<SavedMethod> {
443 let id = method["id"].as_str()?.to_owned();
444 let kind = method["type"].as_str().unwrap_or("card").to_owned();
445 let details = &method[kind.as_str()];
446 Some(SavedMethod {
447 id,
448 brand: details["brand"].as_str().or_else(|| details["bank_name"].as_str()).map(str::to_owned),
449 last4: details["last4"].as_str().map(str::to_owned),
450 exp_month: details["exp_month"].as_u64().map(|m| m as u32),
451 exp_year: details["exp_year"].as_u64().map(|y| y as u32),
452 kind,
453 })
454 }
455}
456
457/// The idempotency key for a payment page: the same workspace, purpose and
458/// details within ten minutes is one page, however often it is asked for (a
459/// double click, two tabs).
460pub(crate) fn page_key(purpose: &str, workspace: &str, details: &str, now_ms: u64) -> String {
461 format!("page/{purpose}/{workspace}/{details}/{}", now_ms / 600_000)
462}
463
464/// Where Stripe sends the person back, with the page's id under `name`.
465fn back_to(return_url: &str, name: &str) -> String {
466 let separator = if return_url.contains('?') { '&' } else { '?' };
467 // Stripe fills in the page's id.
468 format!("{return_url}{separator}{name}={{CHECKOUT_SESSION_ID}}")
469}
470
471/// A prepayment page's fields.
472pub(crate) fn prepay_fields(
473 workspace: &str,
474 amount_cents: u32,
475 customer: Option<&str>,
476 return_url: &str,
477 bank_transfer: bool,
478) -> Vec<(&'static str, String)> {
479 let mut fields = vec![("mode", "payment".to_owned())];
480 if bank_transfer {
481 fields.extend([
482 ("payment_method_types[0]", "customer_balance".to_owned()),
483 ("payment_method_options[customer_balance][funding_type]", "bank_transfer".to_owned()),
484 ("payment_method_options[customer_balance][bank_transfer][type]", "us_bank_transfer".to_owned()),
485 ]);
486 } else {
487 fields.extend([
488 ("payment_method_types[0]", "card".to_owned()),
489 ("payment_method_options[card][request_three_d_secure]", "any".to_owned()),
490 ("payment_intent_data[setup_future_usage]", "off_session".to_owned()),
491 ]);
492 }
493 fields.extend([
494 ("success_url", back_to(return_url, "session")),
495 ("cancel_url", return_url.to_owned()),
496 ("client_reference_id", workspace.to_owned()),
497 ("metadata[workspace]", workspace.to_owned()),
498 ("line_items[0][quantity]", "1".to_owned()),
499 ("line_items[0][price_data][currency]", "usd".to_owned()),
500 ("line_items[0][price_data][unit_amount]", amount_cents.to_string()),
501 ("line_items[0][price_data][product_data][name]", format!("g1t usage paid in advance for {workspace}")),
502 ]);
503 match customer {
504 Some(customer) => fields.push(("customer", customer.to_owned())),
505 None => fields.push(("customer_creation", "always".to_owned())),
506 }
507 fields
508}
509
510/// A plan's page's fields. In subscription mode Stripe makes the customer
511/// itself when there is none; `customer_creation` is for payment mode only.
512pub(crate) fn subscription_fields(
513 workspace: &str,
514 feature: &str,
515 title: &str,
516 monthly_cents: u32,
517 customer: Option<&str>,
518 return_url: &str,
519) -> Vec<(&'static str, String)> {
520 let mut fields = vec![
521 ("mode", "subscription".to_owned()),
522 ("payment_method_types[0]", "card".to_owned()),
523 ("success_url", back_to(return_url, "session")),
524 ("cancel_url", return_url.to_owned()),
525 ("client_reference_id", workspace.to_owned()),
526 ("metadata[workspace]", workspace.to_owned()),
527 ("metadata[feature]", feature.to_owned()),
528 ("subscription_data[metadata][workspace]", workspace.to_owned()),
529 ("subscription_data[metadata][feature]", feature.to_owned()),
530 ("line_items[0][quantity]", "1".to_owned()),
531 ("line_items[0][price_data][currency]", "usd".to_owned()),
532 ("line_items[0][price_data][unit_amount]", monthly_cents.to_string()),
533 ("line_items[0][price_data][recurring][interval]", "month".to_owned()),
534 ("line_items[0][price_data][product_data][name]", format!("g1t {title} for {workspace}")),
535 ];
536 if let Some(customer) = customer {
537 fields.push(("customer", customer.to_owned()));
538 }
539 fields
540}
541
542/// A card check's page's fields: setup mode, nothing charged. Setup mode
543/// takes no line items and no amount.
544pub(crate) fn card_check_fields(workspace: &str, customer: &str, return_url: &str) -> Vec<(&'static str, String)> {
545 vec![
546 ("mode", "setup".to_owned()),
547 ("customer", customer.to_owned()),
548 ("payment_method_types[0]", "card".to_owned()),
549 ("payment_method_options[card][request_three_d_secure]", "any".to_owned()),
550 ("success_url", back_to(return_url, "card_check")),
551 ("cancel_url", return_url.to_owned()),
552 ("client_reference_id", workspace.to_owned()),
553 ("metadata[workspace]", workspace.to_owned()),
554 ("metadata[purpose]", "card_check".to_owned()),
555 ("setup_intent_data[metadata][workspace]", workspace.to_owned()),
556 ("setup_intent_data[description]", format!("Card check for g1t workspace {workspace}; never charged")),
557 ]
558}
559
560/// An AI credit page's fields: the credit, and the card fee as its own
561/// line when there is one.
562pub(crate) fn credit_fields(p: &CreditPurchase<'_>) -> Vec<(&'static str, String)> {
563 let mut fields = vec![
564 ("mode", "payment".to_owned()),
565 ("payment_method_types[0]", "card".to_owned()),
566 ("payment_method_options[card][request_three_d_secure]", "any".to_owned()),
567 // Kept for auto-reload, which charges it with nobody there.
568 ("payment_intent_data[setup_future_usage]", "off_session".to_owned()),
569 ("payment_intent_data[description]", format!("g1t AI credit for {}", p.workspace)),
570 ("payment_intent_data[metadata][workspace]", p.workspace.to_owned()),
571 ("payment_intent_data[metadata][purpose]", "ai_credit".to_owned()),
572 ("success_url", back_to(p.return_url, "ai_credit")),
573 ("cancel_url", p.return_url.to_owned()),
574 ("client_reference_id", p.workspace.to_owned()),
575 ("metadata[workspace]", p.workspace.to_owned()),
576 ("metadata[purpose]", "ai_credit".to_owned()),
577 ("line_items[0][quantity]", "1".to_owned()),
578 ("line_items[0][price_data][currency]", "usd".to_owned()),
579 ("line_items[0][price_data][unit_amount]", p.credit_cents.to_string()),
580 ("line_items[0][price_data][product_data][name]", "g1t AI credit".to_owned()),
581 (
582 "line_items[0][price_data][product_data][description]",
583 format!("Prepaid credit for Agent and AI Gateway usage in {}; expires a year after purchase", p.workspace),
584 ),
585 ];
586 if p.fee_cents > 0 {
587 fields.extend([
588 ("line_items[1][quantity]", "1".to_owned()),
589 ("line_items[1][price_data][currency]", "usd".to_owned()),
590 ("line_items[1][price_data][unit_amount]", p.fee_cents.to_string()),
591 ("line_items[1][price_data][product_data][name]", "Card processing fee".to_owned()),
592 ]);
593 }
594 match p.customer {
595 Some(customer) => fields.push(("customer", customer.to_owned())),
596 None => fields.push(("customer_creation", "always".to_owned())),
597 }
598 fields
599}
600
601/// An off-session charge's fields.
602pub(crate) fn saved_charge_fields(c: &SavedCharge<'_>) -> Vec<(&'static str, String)> {
603 vec![
604 ("amount", (c.credit_cents + c.fee_cents).to_string()),
605 ("currency", "usd".to_owned()),
606 ("customer", c.customer.to_owned()),
607 ("payment_method", c.payment_method.to_owned()),
608 ("off_session", "true".to_owned()),
609 ("confirm", "true".to_owned()),
610 ("description", format!("g1t AI credit auto-reload for {}", c.workspace)),
611 ("metadata[workspace]", c.workspace.to_owned()),
612 ("metadata[purpose]", "ai_reload".to_owned()),
613 ("metadata[credit_cents]", c.credit_cents.to_string()),
614 ("metadata[fee_cents]", c.fee_cents.to_string()),
615 ]
616}
617
618impl Stripe {
619 /// What a card check's setup found, once it succeeded.
620 pub async fn checked_card(&self, setup_intent: &str) -> Result<Option<CheckedCard>> {
621 #[derive(Deserialize)]
622 struct Setup {
623 status: String,
624 payment_method: Option<String>,
625 }
626 #[derive(Deserialize)]
627 struct Card {
628 fingerprint: Option<String>,
629 brand: Option<String>,
630 last4: Option<String>,
631 funding: Option<String>,
632 country: Option<String>,
633 }
634 #[derive(Deserialize)]
635 struct PaymentMethod {
636 card: Option<Card>,
637 }
638 let setup: Setup = self.call(Method::Get, &format!("/setup_intents/{}", encode(setup_intent)), None).await?;
639 let (true, Some(method)) = (setup.status == "succeeded", setup.payment_method) else { return Ok(None) };
640 let found: PaymentMethod = self.call(Method::Get, &format!("/payment_methods/{}", encode(&method)), None).await?;
641 let card = found.card;
642 Ok(Some(CheckedCard {
643 payment_method: method,
644 fingerprint: card.as_ref().and_then(|c| c.fingerprint.clone()),
645 brand: card.as_ref().and_then(|c| c.brand.clone()),
646 last4: card.as_ref().and_then(|c| c.last4.clone()),
647 funding: card.as_ref().and_then(|c| c.funding.clone()),
648 country: card.as_ref().and_then(|c| c.country.clone()),
649 }))
650 }
651
652 /// Makes `payment_method` the card the customer's invoices are charged to.
653 pub async fn set_default_card(&self, customer: &str, payment_method: &str) -> Result<()> {
654 let _: serde_json::Value = self
655 .call(
656 Method::Post,
657 &format!("/customers/{}", encode(customer)),
658 Some(form(&[("invoice_settings[default_payment_method]", payment_method.to_owned())])),
659 )
660 .await?;
661 Ok(())
662 }
663
664 /// A feature's product at Stripe (the plan's is tagged `plan`), made
665 /// the first time it is needed.
666 async fn plan_product(&self, feature: &str, title: &str) -> Result<String> {
667 #[derive(Deserialize)]
668 struct Product {
669 id: String,
670 #[serde(default)]
671 metadata: Option<std::collections::HashMap<String, String>>,
672 }
673 #[derive(Deserialize)]
674 struct List {
675 data: Vec<Product>,
676 }
677 let list: List = self.call(Method::Get, "/products?active=true&limit=100", None).await?;
678 let ours = |p: &Product| p.metadata.as_ref().and_then(|m| m.get("g1t")).map(String::as_str) == Some(feature);
679 if let Some(found) = list.data.into_iter().find(ours) {
680 return Ok(found.id);
681 }
682 let created: Product = self
683 .call(
684 Method::Post,
685 "/products",
686 Some(form(&[("name", format!("{title} plan")), ("metadata[g1t]", feature.to_owned())])),
687 )
688 .await?;
689 Ok(created.id)
690 }
691
692 /// Starts the monthly plan on a saved card, at once. Fails rather than
693 /// leaving it half-started when the card's bank wants the person again;
694 /// the caller then sends them to Stripe's page.
695 pub async fn subscribe_with_card(
696 &self,
697 workspace: &str,
698 feature: &str,
699 title: &str,
700 monthly_cents: u32,
701 customer: &str,
702 payment_method: &str,
703 ) -> Result<StripeSubscription> {
704 let product = self.plan_product(feature, title).await?;
705 let fields = [
706 ("customer", customer.to_owned()),
707 ("default_payment_method", payment_method.to_owned()),
708 ("payment_behavior", "error_if_incomplete".to_owned()),
709 ("items[0][price_data][currency]", "usd".to_owned()),
710 ("items[0][price_data][product]", product),
711 ("items[0][price_data][unit_amount]", monthly_cents.to_string()),
712 ("items[0][price_data][recurring][interval]", "month".to_owned()),
713 ("metadata[workspace]", workspace.to_owned()),
714 ("metadata[feature]", feature.to_owned()),
715 ("description", format!("{title} plan for {workspace}")),
716 ];
717 let key = plan_key(workspace, feature, payment_method, g1t_kit::now_ms());
718 self.send(Method::Post, "/subscriptions", Some(form(&fields)), Some(&key)).await
719 }
720
721 /// Ends a subscription now: one that never started properly.
722 pub async fn cancel_now(&self, id: &str) -> Result<StripeSubscription> {
723 self.call(Method::Delete, &format!("/subscriptions/{}", encode(id)), None).await
724 }
725
726 pub async fn subscription(&self, id: &str) -> Result<StripeSubscription> {
727 self.call(Method::Get, &format!("/subscriptions/{}", encode(id)), None)
728 .await
729 }
730
731 /// Ends a plan when its period does (`cancel` true), or takes that back.
732 pub async fn cancel_at_period_end(&self, id: &str, cancel: bool) -> Result<StripeSubscription> {
733 self.call(
734 Method::Post,
735 &format!("/subscriptions/{}", encode(id)),
736 Some(form(&[("cancel_at_period_end", cancel.to_string())])),
737 )
738 .await
739 }
740
741 pub async fn session(&self, id: &str) -> Result<Session> {
742 self.call(
743 Method::Get,
744 &format!("/checkout/sessions/{}", encode(id)),
745 None,
746 )
747 .await
748 }
749}
750
751/// Whether the processor said an id it was given does not exist, as when
752/// g1t moves to another Stripe account and ids saved from the old one stay
753/// behind.
754pub(crate) fn is_missing(error: &Error) -> bool {
755 error.to_string().contains("resource_missing")
756}
757
758pub(crate) fn is_live(key: &str) -> bool {
759 key.starts_with("sk_live_") || key.starts_with("rk_live_")
760}
761
762#[cfg(test)]
763mod tests {
764 use super::*;
765
766 #[test]
767 fn asking_twice_for_a_plan_starts_one() {
768 let at = 1_791_000_000_000;
769 assert_eq!(plan_key("acme", "plan", "pm_1", at), plan_key("acme", "plan", "pm_1", at + 1_000));
770 assert_ne!(plan_key("acme", "plan", "pm_1", at), plan_key("acme", "plan", "pm_2", at));
771 assert_ne!(plan_key("acme", "plan", "pm_1", at), plan_key("other", "plan", "pm_1", at));
772 }
773
774 #[test]
775 fn form_values_are_percent_encoded() {
776 assert_eq!(
777 form(&[
778 (
779 "success_url",
780 "https://g1t.sh/a/-/billing?session={ID}".to_owned()
781 ),
782 ("line_items[0][quantity]", "1".to_owned()),
783 ]),
784 "success_url=https%3A%2F%2Fg1t.sh%2Fa%2F-%2Fbilling%3Fsession%3D%7BID%7D&line_items%5B0%5D%5Bquantity%5D=1"
785 );
786 }
787
788 #[test]
789 fn test_keys_are_not_live() {
790 assert!(is_live("sk_live_abc"));
791 assert!(!is_live("sk_test_abc"));
792 assert!(!is_live(""));
793 }
794
795 fn has(fields: &[(&str, String)], name: &str) -> Option<String> {
796 fields.iter().find(|(n, _)| *n == name).map(|(_, v)| v.clone())
797 }
798
799 #[test]
800 fn every_request_names_the_api_version_it_is_written_for() {
801 // Without it a new Stripe account answers at its newest version, and
802 // invoices, charges and pages read differently from what billing
803 // expects.
804 assert!(STRIPE_VERSION.starts_with("2025-02-24"));
805 let source = include_str!("stripe.rs");
806 assert!(source.contains(concat!("headers.set(\"stripe-", "version\", STRIPE_VERSION)")));
807 }
808
809 #[test]
810 fn the_plans_page_is_a_subscription_without_payment_mode_fields() {
811 let url = "https://g1t.sh/acme/-/billing?plan=plan";
812 for customer in [None, Some("cus_1")] {
813 let fields = subscription_fields("acme", "plan", "g1t", 2_000, customer, url);
814 assert_eq!(has(&fields, "mode").as_deref(), Some("subscription"));
815 // customer_creation is for payment mode; Stripe refuses it here.
816 assert!(has(&fields, "customer_creation").is_none());
817 assert!(has(&fields, "payment_intent_data[setup_future_usage]").is_none());
818 assert_eq!(has(&fields, "customer").as_deref(), customer);
819 assert_eq!(has(&fields, "success_url").unwrap(), "https://g1t.sh/acme/-/billing?plan=plan&session={CHECKOUT_SESSION_ID}");
820 assert_eq!(has(&fields, "line_items[0][price_data][recurring][interval]").as_deref(), Some("month"));
821 assert!(has(&fields, "automatic_tax[enabled]").is_none());
822 }
823 }
824
825 #[test]
826 fn a_card_check_is_a_setup_page_with_no_amount() {
827 let fields = card_check_fields("acme", "cus_1", "https://g1t.sh/acme/-/billing");
828 assert_eq!(has(&fields, "mode").as_deref(), Some("setup"));
829 assert!(fields.iter().all(|(n, _)| !n.starts_with("line_items")));
830 assert_eq!(has(&fields, "success_url").unwrap(), "https://g1t.sh/acme/-/billing?card_check={CHECKOUT_SESSION_ID}");
831 assert_eq!(has(&fields, "customer").as_deref(), Some("cus_1"));
832 }
833
834 #[test]
835 fn an_ai_credit_page_has_the_card_fee_as_its_own_line() {
836 let purchase = CreditPurchase { workspace: "acme", credit_cents: 2_500, fee_cents: 106, customer: None, return_url: "https://g1t.sh/acme/-/billing" };
837 let fields = credit_fields(&purchase);
838 assert_eq!(has(&fields, "mode").as_deref(), Some("payment"));
839 assert_eq!(has(&fields, "line_items[0][price_data][unit_amount]").as_deref(), Some("2500"));
840 assert_eq!(has(&fields, "line_items[1][price_data][unit_amount]").as_deref(), Some("106"));
841 assert_eq!(has(&fields, "line_items[1][price_data][product_data][name]").as_deref(), Some("Card processing fee"));
842 assert_eq!(has(&fields, "customer_creation").as_deref(), Some("always"));
843 assert_eq!(has(&fields, "payment_intent_data[setup_future_usage]").as_deref(), Some("off_session"));
844 assert_eq!(has(&fields, "success_url").unwrap(), "https://g1t.sh/acme/-/billing?ai_credit={CHECKOUT_SESSION_ID}");
845 // No fee: no second line.
846 let fields = credit_fields(&CreditPurchase { fee_cents: 0, customer: Some("cus_1"), ..purchase });
847 assert!(has(&fields, "line_items[1][quantity]").is_none());
848 assert!(has(&fields, "customer_creation").is_none());
849 }
850
851 #[test]
852 fn an_auto_reload_is_one_off_session_charge() {
853 let charge = SavedCharge { workspace: "acme", customer: "cus_1", payment_method: "pm_1", credit_cents: 1_600, fee_cents: 78, key: "reload/acme/2026-10/1" };
854 let fields = saved_charge_fields(&charge);
855 assert_eq!(has(&fields, "amount").as_deref(), Some("1678"));
856 assert_eq!(has(&fields, "off_session").as_deref(), Some("true"));
857 assert_eq!(has(&fields, "confirm").as_deref(), Some("true"));
858 }
859
860 #[test]
861 fn a_page_asked_for_twice_is_one_page() {
862 let at = 1_791_000_000_000;
863 assert_eq!(page_key("plan", "acme", "plan/2000", at), page_key("plan", "acme", "plan/2000", at + 60_000));
864 assert_ne!(page_key("plan", "acme", "plan/2000", at), page_key("ai_credit", "acme", "plan/2000", at));
865 }
866
867 #[test]
868 fn a_stripe_failure_reads_as_a_sentence_never_raw() {
869 let refused = Error::RustError(
870 r#"the card processor answered 400: {"error":{"message":"You may only specify one of these parameters: customer, customer_creation.","type":"invalid_request_error"}}"#.into(),
871 );
872 assert_eq!(friendly(&refused), "Stripe refused it: You may only specify one of these parameters: customer, customer_creation.");
873 assert!(!friendly(&refused).contains("invalid_request_error"));
874 let odd = Error::RustError("the card processor answered 502: <html>".into());
875 assert!(friendly(&odd).starts_with("Stripe refused it. Try again"));
876 let down = Error::RustError("network connection lost".into());
877 assert_eq!(friendly(&down), "Stripe could not be reached. Try again in a minute.");
878 let declined = Error::RustError(r#"the card processor answered 402: {"error":{"type":"card_error","code":"card_declined","message":"Your card was declined."}}"#.into());
879 assert!(is_card_error(&declined) && !is_card_error(&refused));
880 }
881}