1#![doc = include_str!("../README.md")]
20#![forbid(unsafe_code)]
21
22use std::collections::BTreeMap;
23use std::sync::Arc;
24use std::time::Duration;
25
26use async_trait::async_trait;
27use bytes::Bytes;
28use cratefield_core::{
29 Charge, CheckoutRequest, CheckoutSession, Clock, ConnectAccountLink, ConnectAccountLinkRequest,
30 HttpClient, Money, Payments, PaymentsError, PortalSession, PortalSessionRequest, Refund,
31 RefundRequest, Subscription, SubscriptionCheckoutRequest, SubscriptionStatus, TransferCharge,
32 UsageReport, UsageReported, WebhookEvent,
33};
34use hmac::{Hmac, KeyInit, Mac};
35use http::header::{AUTHORIZATION, CONTENT_TYPE};
36use http::{Request, StatusCode};
37use serde_json::Value;
38use sha2::Sha256;
39use subtle::{Choice, ConstantTimeEq};
40use time::OffsetDateTime;
41
42const STRIPE_API_BASE: &str = "https://api.stripe.com";
43
44pub const WEBHOOK_TOLERANCE: Duration = Duration::from_secs(300);
47
48pub struct Stripe {
50 inner: Inner,
51}
52
53enum Inner {
54 Live(Box<Live>),
55 NotConfigured,
56}
57
58struct Live {
59 http: Arc<dyn HttpClient>,
60 clock: Arc<dyn Clock>,
61 secret_key: String,
62 webhook_secret: String,
66 base_url: String,
67}
68
69impl Stripe {
70 #[must_use]
74 pub fn new(
75 http: Arc<dyn HttpClient>,
76 clock: Arc<dyn Clock>,
77 secret_key: impl Into<String>,
78 webhook_secret: impl Into<String>,
79 ) -> Self {
80 Self {
81 inner: Inner::Live(Box::new(Live {
82 http,
83 clock,
84 secret_key: secret_key.into(),
85 webhook_secret: webhook_secret.into(),
86 base_url: STRIPE_API_BASE.to_owned(),
87 })),
88 }
89 }
90
91 #[must_use]
94 pub fn not_configured() -> Self {
95 Self {
96 inner: Inner::NotConfigured,
97 }
98 }
99
100 #[must_use]
103 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
104 if let Inner::Live(live) = &mut self.inner {
105 live.base_url = base_url.into();
106 }
107 self
108 }
109
110 fn live(&self) -> Result<&Live, PaymentsError> {
111 match &self.inner {
112 Inner::Live(live) => Ok(live),
113 Inner::NotConfigured => Err(PaymentsError::NotConfigured),
114 }
115 }
116}
117
118#[derive(Default)]
121struct Form {
122 pairs: Vec<(String, String)>,
123}
124
125impl Form {
126 fn field(&mut self, key: &str, value: impl Into<String>) -> &mut Self {
127 self.pairs.push((key.to_owned(), value.into()));
128 self
129 }
130
131 fn field_opt(&mut self, key: &str, value: Option<&str>) -> &mut Self {
132 if let Some(value) = value {
133 self.field(key, value.to_owned());
134 }
135 self
136 }
137
138 fn metadata(&mut self, metadata: &std::collections::BTreeMap<String, String>) -> &mut Self {
139 for (key, value) in metadata {
140 self.field(&format!("metadata[{key}]"), value.clone());
141 }
142 self
143 }
144
145 fn encode(&self) -> String {
146 self.pairs
147 .iter()
148 .map(|(key, value)| format!("{}={}", percent_encode(key), percent_encode(value)))
149 .collect::<Vec<_>>()
150 .join("&")
151 }
152}
153
154fn percent_encode(input: &str) -> String {
157 let mut out = String::with_capacity(input.len());
158 for &byte in input.as_bytes() {
159 match byte {
160 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
161 out.push(byte as char);
162 }
163 other => {
164 use std::fmt::Write as _;
165 let _ = write!(out, "%{other:02X}");
166 }
167 }
168 }
169 out
170}
171
172impl Live {
173 async fn post(
176 &self,
177 path: &str,
178 idempotency_key: &str,
179 form: &Form,
180 ) -> Result<Value, PaymentsError> {
181 let (status, body) = self.post_raw(path, idempotency_key, form).await?;
182 if status.is_success() {
183 return serde_json::from_slice(&body).map_err(|err| {
184 PaymentsError::Rejected(format!("unparseable Stripe response: {err}"))
185 });
186 }
187 Err(map_error(status, &body))
188 }
189
190 async fn post_raw(
194 &self,
195 path: &str,
196 idempotency_key: &str,
197 form: &Form,
198 ) -> Result<(StatusCode, Bytes), PaymentsError> {
199 let url = format!("{}/v1/{path}", self.base_url);
200 let request = Request::builder()
201 .method("POST")
202 .uri(&url)
203 .header(AUTHORIZATION, format!("Bearer {}", self.secret_key))
204 .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
205 .header("Idempotency-Key", idempotency_key)
206 .header("Stripe-Version", "2024-06-20")
207 .body(Bytes::from(form.encode()))
208 .map_err(|err| PaymentsError::Rejected(format!("could not build request: {err}")))?;
209
210 let response = self
211 .http
212 .send(request)
213 .await
214 .map_err(|err| PaymentsError::Transient(err.to_string()))?;
215
216 Ok((response.status(), response.into_body()))
217 }
218
219 async fn get(&self, path: &str, query: &[(&str, String)]) -> Result<Value, PaymentsError> {
223 let mut url = format!("{}/v1/{path}", self.base_url);
224 for (index, (key, value)) in query.iter().enumerate() {
225 url.push(if index == 0 { '?' } else { '&' });
226 url.push_str(&percent_encode(key));
227 url.push('=');
228 url.push_str(&percent_encode(value));
229 }
230
231 let request = Request::builder()
232 .method("GET")
233 .uri(&url)
234 .header(AUTHORIZATION, format!("Bearer {}", self.secret_key))
235 .header("Stripe-Version", "2024-06-20")
236 .body(Bytes::new())
237 .map_err(|err| PaymentsError::Rejected(format!("could not build request: {err}")))?;
238
239 let response = self
240 .http
241 .send(request)
242 .await
243 .map_err(|err| PaymentsError::Transient(err.to_string()))?;
244
245 let status = response.status();
246 let body = response.into_body();
247 if status.is_success() {
248 return serde_json::from_slice(&body).map_err(|err| {
249 PaymentsError::Rejected(format!("unparseable Stripe response: {err}"))
250 });
251 }
252 Err(map_error(status, &body))
253 }
254}
255
256fn error_fields(body: &[u8]) -> (Option<String>, Option<String>) {
259 let Ok(value) = serde_json::from_slice::<Value>(body) else {
260 return (None, None);
261 };
262 let error = value.get("error");
263 let string = |key: &str| {
264 error
265 .and_then(|error| error.get(key))
266 .and_then(Value::as_str)
267 .map(str::to_owned)
268 };
269 (string("code"), string("message"))
270}
271
272fn map_error(status: StatusCode, body: &[u8]) -> PaymentsError {
276 let (code, message) = error_fields(body);
277 let detail = match (code, message) {
278 (Some(code), Some(message)) => format!("{code}: {message}"),
279 (Some(code), None) => code,
280 (None, Some(message)) => message,
281 (None, None) => status.as_u16().to_string(),
282 };
283
284 if status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
285 PaymentsError::Transient(format!("stripe {}: {detail}", status.as_u16()))
286 } else {
287 PaymentsError::Rejected(format!("stripe {}: {detail}", status.as_u16()))
288 }
289}
290
291fn field<'a>(object: &'a Value, key: &str) -> Result<&'a str, PaymentsError> {
293 object
294 .get(key)
295 .and_then(Value::as_str)
296 .ok_or_else(|| PaymentsError::Rejected(format!("Stripe response missing `{key}`")))
297}
298
299fn path_segment(id: &str) -> Result<&str, PaymentsError> {
303 if !id.is_empty() && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
304 Ok(id)
305 } else {
306 Err(PaymentsError::Rejected("not a Stripe id".to_owned()))
307 }
308}
309
310fn customer_ref(object: &Value) -> Result<String, PaymentsError> {
313 match object.get("customer") {
314 Some(Value::String(id)) => Ok(id.clone()),
315 Some(customer) => customer
316 .get("id")
317 .and_then(Value::as_str)
318 .map(str::to_owned)
319 .ok_or_else(|| {
320 PaymentsError::Rejected("Stripe customer object missing `id`".to_owned())
321 }),
322 None => Err(PaymentsError::Rejected(
323 "Stripe response missing `customer`".to_owned(),
324 )),
325 }
326}
327
328fn metadata_from(object: &Value) -> BTreeMap<String, String> {
332 object
333 .get("metadata")
334 .and_then(Value::as_object)
335 .map(|metadata| {
336 metadata
337 .iter()
338 .filter_map(|(key, value)| {
339 value.as_str().map(|value| (key.clone(), value.to_owned()))
340 })
341 .collect()
342 })
343 .unwrap_or_default()
344}
345
346fn subscription_from(object: &Value) -> Result<Subscription, PaymentsError> {
348 let item = object
349 .get("items")
350 .and_then(|items| items.get("data"))
351 .and_then(Value::as_array)
352 .and_then(|items| items.first());
353
354 let price_ref = item
355 .and_then(|item| item.get("price"))
356 .and_then(|price| match price {
357 Value::String(id) => Some(id.clone()),
358 price => price.get("id").and_then(Value::as_str).map(str::to_owned),
359 });
360
361 let quantity = item
362 .and_then(|item| item.get("quantity"))
363 .and_then(Value::as_u64)
364 .and_then(|quantity| u32::try_from(quantity).ok())
365 .unwrap_or(1);
366
367 let current_period_end = object
371 .get("current_period_end")
372 .and_then(Value::as_i64)
373 .or_else(|| {
374 item.and_then(|item| item.get("current_period_end"))
375 .and_then(Value::as_i64)
376 })
377 .and_then(|unix| OffsetDateTime::from_unix_timestamp(unix).ok());
378
379 Ok(Subscription {
380 id: field(object, "id")?.to_owned(),
381 customer_ref: customer_ref(object)?,
382 status: SubscriptionStatus::from_provider(field(object, "status")?),
383 price_ref,
384 quantity,
385 current_period_end,
386 cancel_at_period_end: object
387 .get("cancel_at_period_end")
388 .and_then(Value::as_bool)
389 .unwrap_or(false),
390 metadata: metadata_from(object),
391 })
392}
393
394#[async_trait]
395impl Payments for Stripe {
396 async fn create_checkout(
397 &self,
398 request: &CheckoutRequest,
399 ) -> Result<CheckoutSession, PaymentsError> {
400 let live = self.live()?;
401 let mut form = Form::default();
402 form.field("mode", "payment")
403 .field("success_url", request.success_url.clone())
404 .field("cancel_url", request.cancel_url.clone())
405 .field_opt("customer", request.customer_ref.as_deref())
406 .field_opt("customer_email", request.customer_email.as_deref())
407 .field("line_items[0][quantity]", request.line.quantity.to_string())
408 .field(
409 "line_items[0][price_data][currency]",
410 request.line.amount.currency.clone(),
411 )
412 .field(
413 "line_items[0][price_data][unit_amount]",
414 request.line.amount.minor_units.to_string(),
415 )
416 .field(
417 "line_items[0][price_data][product_data][name]",
418 request.line.name.clone(),
419 )
420 .metadata(&request.metadata);
421
422 let object = live
423 .post("checkout/sessions", &request.idempotency_key, &form)
424 .await?;
425 Ok(CheckoutSession {
426 id: field(&object, "id")?.to_owned(),
427 url: field(&object, "url")?.to_owned(),
428 })
429 }
430
431 async fn create_subscription_checkout(
432 &self,
433 request: &SubscriptionCheckoutRequest,
434 ) -> Result<CheckoutSession, PaymentsError> {
435 let live = self.live()?;
436 let mut form = Form::default();
437 form.field("mode", "subscription")
438 .field("success_url", request.success_url.clone())
439 .field("cancel_url", request.cancel_url.clone())
440 .field_opt("customer", request.customer_ref.as_deref())
441 .field_opt("customer_email", request.customer_email.as_deref())
442 .field("line_items[0][price]", request.price_ref.clone())
443 .field("line_items[0][quantity]", "1");
444 if let Some(days) = request.trial_days {
445 form.field("subscription_data[trial_period_days]", days.to_string());
446 }
447 form.metadata(&request.metadata);
448
449 let object = live
450 .post("checkout/sessions", &request.idempotency_key, &form)
451 .await?;
452 Ok(CheckoutSession {
453 id: field(&object, "id")?.to_owned(),
454 url: field(&object, "url")?.to_owned(),
455 })
456 }
457
458 async fn create_portal_session(
459 &self,
460 request: &PortalSessionRequest,
461 ) -> Result<PortalSession, PaymentsError> {
462 let live = self.live()?;
463 let mut form = Form::default();
464 form.field("customer", request.customer_ref.clone())
465 .field("return_url", request.return_url.clone());
466
467 let object = live
468 .post("billing_portal/sessions", &request.idempotency_key, &form)
469 .await?;
470 Ok(PortalSession {
471 url: field(&object, "url")?.to_owned(),
472 })
473 }
474
475 async fn get_subscription(
476 &self,
477 subscription_ref: &str,
478 ) -> Result<Subscription, PaymentsError> {
479 let live = self.live()?;
480 let id = path_segment(subscription_ref)?;
481 let object = live.get(&format!("subscriptions/{id}"), &[]).await?;
482 subscription_from(&object)
483 }
484
485 async fn list_subscriptions(
486 &self,
487 customer_ref: &str,
488 ) -> Result<Vec<Subscription>, PaymentsError> {
489 let live = self.live()?;
490 let mut subscriptions = Vec::new();
495 let mut starting_after: Option<String> = None;
496 loop {
497 let mut query = vec![
498 ("customer", customer_ref.to_owned()),
499 ("status", "all".to_owned()),
500 ("limit", "100".to_owned()),
501 ];
502 if let Some(after) = &starting_after {
503 query.push(("starting_after", after.clone()));
504 }
505 let page = live.get("subscriptions", &query).await?;
506 let data = page.get("data").and_then(Value::as_array).ok_or_else(|| {
507 PaymentsError::Rejected("Stripe response missing `data`".to_owned())
508 })?;
509 for object in data {
510 subscriptions.push(subscription_from(object)?);
511 }
512
513 let has_more = page
514 .get("has_more")
515 .and_then(Value::as_bool)
516 .unwrap_or(false);
517 let last_id = data
518 .last()
519 .and_then(|object| object.get("id"))
520 .and_then(Value::as_str);
521 match (has_more, last_id) {
522 (true, Some(id)) if Some(id) == starting_after.as_deref() => {
526 tracing::warn!(
527 customer = %customer_ref,
528 cursor = %id,
529 "stripe page did not advance the pagination cursor; stopping"
530 );
531 break;
532 }
533 (true, Some(id)) => starting_after = Some(id.to_owned()),
534 _ => break,
535 }
536 }
537 Ok(subscriptions)
538 }
539
540 async fn create_connect_account_link(
541 &self,
542 request: &ConnectAccountLinkRequest,
543 ) -> Result<ConnectAccountLink, PaymentsError> {
544 let live = self.live()?;
545 let account_id = if let Some(account_id) = &request.account_ref {
547 account_id.clone()
548 } else {
549 let mut form = Form::default();
550 form.field("type", "express");
551 let object = live
552 .post(
553 "accounts",
554 &format!("{}-acct", request.idempotency_key),
555 &form,
556 )
557 .await?;
558 field(&object, "id")?.to_owned()
559 };
560
561 let mut form = Form::default();
562 form.field("account", account_id.clone())
563 .field("refresh_url", request.refresh_url.clone())
564 .field("return_url", request.return_url.clone())
565 .field("type", "account_onboarding");
566 let object = live
567 .post(
568 "account_links",
569 &format!("{}-link", request.idempotency_key),
570 &form,
571 )
572 .await?;
573 Ok(ConnectAccountLink {
574 account_id,
575 url: field(&object, "url")?.to_owned(),
576 })
577 }
578
579 async fn charge_with_transfer(
580 &self,
581 request: &TransferCharge,
582 ) -> Result<Charge, PaymentsError> {
583 let live = self.live()?;
584 let mut form = Form::default();
585 form.field("amount", request.amount.minor_units.to_string())
586 .field("currency", request.amount.currency.clone())
587 .field_opt("customer", request.customer_ref.as_deref())
588 .field(
589 "application_fee_amount",
590 request.application_fee.minor_units.to_string(),
591 )
592 .field(
593 "transfer_data[destination]",
594 request.destination_account.clone(),
595 )
596 .metadata(&request.metadata);
597
598 let object = live
599 .post("payment_intents", &request.idempotency_key, &form)
600 .await?;
601 Ok(Charge {
602 id: field(&object, "id")?.to_owned(),
603 status: field(&object, "status")?.to_owned(),
604 })
605 }
606
607 async fn refund(&self, request: &RefundRequest) -> Result<Refund, PaymentsError> {
608 let live = self.live()?;
609 let mut form = Form::default();
610 form.field("payment_intent", request.payment_ref.clone());
611 if let Some(Money { minor_units, .. }) = &request.amount {
612 form.field("amount", minor_units.to_string());
613 }
614
615 let object = live
616 .post("refunds", &request.idempotency_key, &form)
617 .await?;
618 Ok(Refund {
619 id: field(&object, "id")?.to_owned(),
620 })
621 }
622
623 async fn verify_webhook(
624 &self,
625 signature_header: &str,
626 body: &[u8],
627 ) -> Result<WebhookEvent, PaymentsError> {
628 let live = self.live()?;
629 if live.webhook_secret.is_empty() {
630 return Err(PaymentsError::NotConfigured);
631 }
632
633 let (timestamp, signatures) = parse_signature_header(signature_header)?;
634
635 let now = live.clock.now().unix_timestamp();
637 if now.saturating_sub(timestamp).unsigned_abs() > WEBHOOK_TOLERANCE.as_secs() {
638 return Err(PaymentsError::SignatureInvalid(
639 "timestamp outside tolerance".to_owned(),
640 ));
641 }
642
643 let mut mac = Hmac::<Sha256>::new_from_slice(live.webhook_secret.as_bytes())
651 .map_err(|err| PaymentsError::SignatureInvalid(err.to_string()))?;
652 mac.update(timestamp.to_string().as_bytes());
653 mac.update(b".");
654 mac.update(body);
655 let expected = mac.finalize().into_bytes();
656
657 let mut matched = Choice::from(0u8);
658 for candidate in &signatures {
659 if let Some(bytes) = hex_decode(candidate) {
660 matched |= bytes.ct_eq(expected.as_slice());
661 }
662 }
663 if !bool::from(matched) {
664 return Err(PaymentsError::SignatureInvalid(
665 "no signature matched".to_owned(),
666 ));
667 }
668
669 let event: Value = serde_json::from_slice(body)
670 .map_err(|err| PaymentsError::SignatureInvalid(format!("unparseable event: {err}")))?;
671 Ok(WebhookEvent {
672 id: field(&event, "id")?.to_owned(),
673 kind: field(&event, "type")?.to_owned(),
674 data: event
675 .get("data")
676 .and_then(|data| data.get("object"))
677 .cloned()
678 .unwrap_or(Value::Null),
679 })
680 }
681
682 async fn report_usage(&self, report: &UsageReport) -> Result<UsageReported, PaymentsError> {
683 let live = self.live()?;
684 if report.identifier.is_empty()
687 || report.meter_event_name.is_empty()
688 || report.customer_ref.is_empty()
689 {
690 return Err(PaymentsError::Rejected(
691 "usage report needs a non-empty identifier, meter event name and customer"
692 .to_owned(),
693 ));
694 }
695
696 let mut form = Form::default();
697 form.field("event_name", report.meter_event_name.clone())
698 .field("payload[stripe_customer_id]", report.customer_ref.clone())
699 .field("payload[value]", report.value.to_string())
700 .field("identifier", report.identifier.clone())
701 .field("timestamp", report.timestamp.unix_timestamp().to_string());
702
703 let (status, body) = live
706 .post_raw("billing/meter_events", &report.identifier, &form)
707 .await?;
708
709 if status.is_success() {
710 return Ok(UsageReported {
711 identifier: report.identifier.clone(),
712 already_reported: false,
713 });
714 }
715
716 let (code, message) = error_fields(&body);
717
718 if status == StatusCode::CONFLICT {
722 return Err(PaymentsError::Transient(format!(
723 "stripe 409: {}",
724 message.as_deref().unwrap_or("too many concurrent requests")
725 )));
726 }
727
728 let duplicate = match code.as_deref() {
734 Some(code) => code == "duplicate_meter_event",
735 None => message.as_deref().is_some_and(|message| {
736 let message = message.to_ascii_lowercase();
737 message.contains("duplicate") && message.contains("identifier")
738 }),
739 };
740 if status == StatusCode::BAD_REQUEST && duplicate {
741 return Ok(UsageReported {
742 identifier: report.identifier.clone(),
743 already_reported: true,
744 });
745 }
746
747 Err(map_error(status, &body))
748 }
749}
750
751fn parse_signature_header(header: &str) -> Result<(i64, Vec<String>), PaymentsError> {
754 let mut timestamp = None;
755 let mut signatures = Vec::new();
756 for part in header.split(',') {
757 let Some((key, value)) = part.split_once('=') else {
758 continue;
759 };
760 match key.trim() {
761 "t" => timestamp = value.trim().parse::<i64>().ok(),
762 "v1" => signatures.push(value.trim().to_owned()),
763 _ => {}
764 }
765 }
766 let timestamp = timestamp
767 .ok_or_else(|| PaymentsError::SignatureInvalid("no timestamp in header".to_owned()))?;
768 if signatures.is_empty() {
769 return Err(PaymentsError::SignatureInvalid(
770 "no v1 signature in header".to_owned(),
771 ));
772 }
773 Ok((timestamp, signatures))
774}
775
776fn hex_decode(input: &str) -> Option<Vec<u8>> {
778 if !input.len().is_multiple_of(2) {
779 return None;
780 }
781 let mut out = Vec::with_capacity(input.len() / 2);
782 let bytes = input.as_bytes();
783 let mut index = 0;
784 while index < bytes.len() {
785 let high = (bytes[index] as char).to_digit(16)?;
786 let low = (bytes[index + 1] as char).to_digit(16)?;
787 out.push(u8::try_from(high * 16 + low).ok()?);
788 index += 2;
789 }
790 Some(out)
791}