1#![doc = include_str!("../README.md")]
20#![forbid(unsafe_code)]
21
22use std::sync::Arc;
23use std::time::Duration;
24
25use async_trait::async_trait;
26use bytes::Bytes;
27use cratefield_core::{
28 Charge, CheckoutRequest, CheckoutSession, Clock, ConnectAccountLink, ConnectAccountLinkRequest,
29 HttpClient, Money, Payments, PaymentsError, Refund, RefundRequest, SubscriptionCheckoutRequest,
30 TransferCharge, WebhookEvent,
31};
32use hmac::{Hmac, KeyInit, Mac};
33use http::header::{AUTHORIZATION, CONTENT_TYPE};
34use http::{Request, StatusCode};
35use serde_json::Value;
36use sha2::Sha256;
37
38const STRIPE_API_BASE: &str = "https://api.stripe.com";
39
40pub const WEBHOOK_TOLERANCE: Duration = Duration::from_secs(300);
43
44pub struct Stripe {
46 inner: Inner,
47}
48
49enum Inner {
50 Live(Box<Live>),
51 NotConfigured,
52}
53
54struct Live {
55 http: Arc<dyn HttpClient>,
56 clock: Arc<dyn Clock>,
57 secret_key: String,
58 webhook_secret: String,
62 base_url: String,
63}
64
65impl Stripe {
66 #[must_use]
70 pub fn new(
71 http: Arc<dyn HttpClient>,
72 clock: Arc<dyn Clock>,
73 secret_key: impl Into<String>,
74 webhook_secret: impl Into<String>,
75 ) -> Self {
76 Self {
77 inner: Inner::Live(Box::new(Live {
78 http,
79 clock,
80 secret_key: secret_key.into(),
81 webhook_secret: webhook_secret.into(),
82 base_url: STRIPE_API_BASE.to_owned(),
83 })),
84 }
85 }
86
87 #[must_use]
90 pub fn not_configured() -> Self {
91 Self {
92 inner: Inner::NotConfigured,
93 }
94 }
95
96 #[must_use]
99 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
100 if let Inner::Live(live) = &mut self.inner {
101 live.base_url = base_url.into();
102 }
103 self
104 }
105
106 fn live(&self) -> Result<&Live, PaymentsError> {
107 match &self.inner {
108 Inner::Live(live) => Ok(live),
109 Inner::NotConfigured => Err(PaymentsError::NotConfigured),
110 }
111 }
112}
113
114#[derive(Default)]
117struct Form {
118 pairs: Vec<(String, String)>,
119}
120
121impl Form {
122 fn field(&mut self, key: &str, value: impl Into<String>) -> &mut Self {
123 self.pairs.push((key.to_owned(), value.into()));
124 self
125 }
126
127 fn field_opt(&mut self, key: &str, value: Option<&str>) -> &mut Self {
128 if let Some(value) = value {
129 self.field(key, value.to_owned());
130 }
131 self
132 }
133
134 fn metadata(&mut self, metadata: &std::collections::BTreeMap<String, String>) -> &mut Self {
135 for (key, value) in metadata {
136 self.field(&format!("metadata[{key}]"), value.clone());
137 }
138 self
139 }
140
141 fn encode(&self) -> String {
142 self.pairs
143 .iter()
144 .map(|(key, value)| format!("{}={}", percent_encode(key), percent_encode(value)))
145 .collect::<Vec<_>>()
146 .join("&")
147 }
148}
149
150fn percent_encode(input: &str) -> String {
153 let mut out = String::with_capacity(input.len());
154 for &byte in input.as_bytes() {
155 match byte {
156 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
157 out.push(byte as char);
158 }
159 other => {
160 use std::fmt::Write as _;
161 let _ = write!(out, "%{other:02X}");
162 }
163 }
164 }
165 out
166}
167
168impl Live {
169 async fn post(
172 &self,
173 path: &str,
174 idempotency_key: &str,
175 form: &Form,
176 ) -> Result<Value, PaymentsError> {
177 let url = format!("{}/v1/{path}", self.base_url);
178 let request = Request::builder()
179 .method("POST")
180 .uri(&url)
181 .header(AUTHORIZATION, format!("Bearer {}", self.secret_key))
182 .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
183 .header("Idempotency-Key", idempotency_key)
184 .header("Stripe-Version", "2024-06-20")
185 .body(Bytes::from(form.encode()))
186 .map_err(|err| PaymentsError::Rejected(format!("could not build request: {err}")))?;
187
188 let response = self
189 .http
190 .send(request)
191 .await
192 .map_err(|err| PaymentsError::Transient(err.to_string()))?;
193
194 let status = response.status();
195 let body = response.into_body();
196 if status.is_success() {
197 return serde_json::from_slice(&body).map_err(|err| {
198 PaymentsError::Rejected(format!("unparseable Stripe response: {err}"))
199 });
200 }
201 Err(map_error(status, &body))
202 }
203}
204
205fn map_error(status: StatusCode, body: &[u8]) -> PaymentsError {
208 let detail = serde_json::from_slice::<Value>(body)
209 .ok()
210 .and_then(|value| {
211 value
212 .get("error")
213 .and_then(|error| error.get("message"))
214 .and_then(Value::as_str)
215 .map(str::to_owned)
216 })
217 .unwrap_or_else(|| status.as_u16().to_string());
218
219 if status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
220 PaymentsError::Transient(format!("stripe {}: {detail}", status.as_u16()))
221 } else {
222 PaymentsError::Rejected(format!("stripe {}: {detail}", status.as_u16()))
223 }
224}
225
226fn field<'a>(object: &'a Value, key: &str) -> Result<&'a str, PaymentsError> {
228 object
229 .get(key)
230 .and_then(Value::as_str)
231 .ok_or_else(|| PaymentsError::Rejected(format!("Stripe response missing `{key}`")))
232}
233
234#[async_trait]
235impl Payments for Stripe {
236 async fn create_checkout(
237 &self,
238 request: &CheckoutRequest,
239 ) -> Result<CheckoutSession, PaymentsError> {
240 let live = self.live()?;
241 let mut form = Form::default();
242 form.field("mode", "payment")
243 .field("success_url", request.success_url.clone())
244 .field("cancel_url", request.cancel_url.clone())
245 .field_opt("customer", request.customer_ref.as_deref())
246 .field_opt("customer_email", request.customer_email.as_deref())
247 .field("line_items[0][quantity]", request.line.quantity.to_string())
248 .field(
249 "line_items[0][price_data][currency]",
250 request.line.amount.currency.clone(),
251 )
252 .field(
253 "line_items[0][price_data][unit_amount]",
254 request.line.amount.minor_units.to_string(),
255 )
256 .field(
257 "line_items[0][price_data][product_data][name]",
258 request.line.name.clone(),
259 )
260 .metadata(&request.metadata);
261
262 let object = live
263 .post("checkout/sessions", &request.idempotency_key, &form)
264 .await?;
265 Ok(CheckoutSession {
266 id: field(&object, "id")?.to_owned(),
267 url: field(&object, "url")?.to_owned(),
268 })
269 }
270
271 async fn create_subscription_checkout(
272 &self,
273 request: &SubscriptionCheckoutRequest,
274 ) -> Result<CheckoutSession, PaymentsError> {
275 let live = self.live()?;
276 let mut form = Form::default();
277 form.field("mode", "subscription")
278 .field("success_url", request.success_url.clone())
279 .field("cancel_url", request.cancel_url.clone())
280 .field_opt("customer", request.customer_ref.as_deref())
281 .field_opt("customer_email", request.customer_email.as_deref())
282 .field("line_items[0][price]", request.price_ref.clone())
283 .field("line_items[0][quantity]", "1");
284 if let Some(days) = request.trial_days {
285 form.field("subscription_data[trial_period_days]", days.to_string());
286 }
287 form.metadata(&request.metadata);
288
289 let object = live
290 .post("checkout/sessions", &request.idempotency_key, &form)
291 .await?;
292 Ok(CheckoutSession {
293 id: field(&object, "id")?.to_owned(),
294 url: field(&object, "url")?.to_owned(),
295 })
296 }
297
298 async fn create_connect_account_link(
299 &self,
300 request: &ConnectAccountLinkRequest,
301 ) -> Result<ConnectAccountLink, PaymentsError> {
302 let live = self.live()?;
303 let account_id = if let Some(account_id) = &request.account_ref {
305 account_id.clone()
306 } else {
307 let mut form = Form::default();
308 form.field("type", "express");
309 let object = live
310 .post(
311 "accounts",
312 &format!("{}-acct", request.idempotency_key),
313 &form,
314 )
315 .await?;
316 field(&object, "id")?.to_owned()
317 };
318
319 let mut form = Form::default();
320 form.field("account", account_id.clone())
321 .field("refresh_url", request.refresh_url.clone())
322 .field("return_url", request.return_url.clone())
323 .field("type", "account_onboarding");
324 let object = live
325 .post(
326 "account_links",
327 &format!("{}-link", request.idempotency_key),
328 &form,
329 )
330 .await?;
331 Ok(ConnectAccountLink {
332 account_id,
333 url: field(&object, "url")?.to_owned(),
334 })
335 }
336
337 async fn charge_with_transfer(
338 &self,
339 request: &TransferCharge,
340 ) -> Result<Charge, PaymentsError> {
341 let live = self.live()?;
342 let mut form = Form::default();
343 form.field("amount", request.amount.minor_units.to_string())
344 .field("currency", request.amount.currency.clone())
345 .field_opt("customer", request.customer_ref.as_deref())
346 .field(
347 "application_fee_amount",
348 request.application_fee.minor_units.to_string(),
349 )
350 .field(
351 "transfer_data[destination]",
352 request.destination_account.clone(),
353 )
354 .metadata(&request.metadata);
355
356 let object = live
357 .post("payment_intents", &request.idempotency_key, &form)
358 .await?;
359 Ok(Charge {
360 id: field(&object, "id")?.to_owned(),
361 status: field(&object, "status")?.to_owned(),
362 })
363 }
364
365 async fn refund(&self, request: &RefundRequest) -> Result<Refund, PaymentsError> {
366 let live = self.live()?;
367 let mut form = Form::default();
368 form.field("payment_intent", request.payment_ref.clone());
369 if let Some(Money { minor_units, .. }) = &request.amount {
370 form.field("amount", minor_units.to_string());
371 }
372
373 let object = live
374 .post("refunds", &request.idempotency_key, &form)
375 .await?;
376 Ok(Refund {
377 id: field(&object, "id")?.to_owned(),
378 })
379 }
380
381 async fn verify_webhook(
382 &self,
383 signature_header: &str,
384 body: &[u8],
385 ) -> Result<WebhookEvent, PaymentsError> {
386 let live = self.live()?;
387 if live.webhook_secret.is_empty() {
388 return Err(PaymentsError::NotConfigured);
389 }
390
391 let (timestamp, signatures) = parse_signature_header(signature_header)?;
392
393 let now = live.clock.now().unix_timestamp();
395 if now.saturating_sub(timestamp).unsigned_abs() > WEBHOOK_TOLERANCE.as_secs() {
396 return Err(PaymentsError::SignatureInvalid(
397 "timestamp outside tolerance".to_owned(),
398 ));
399 }
400
401 let mut mac = Hmac::<Sha256>::new_from_slice(live.webhook_secret.as_bytes())
404 .map_err(|err| PaymentsError::SignatureInvalid(err.to_string()))?;
405 mac.update(timestamp.to_string().as_bytes());
406 mac.update(b".");
407 mac.update(body);
408 let expected = mac.finalize().into_bytes();
409
410 let matched = signatures.iter().any(|candidate| {
411 hex_decode(candidate).is_some_and(|bytes| bytes.as_slice() == expected.as_slice())
412 });
413 if !matched {
414 return Err(PaymentsError::SignatureInvalid(
415 "no signature matched".to_owned(),
416 ));
417 }
418
419 let event: Value = serde_json::from_slice(body)
420 .map_err(|err| PaymentsError::SignatureInvalid(format!("unparseable event: {err}")))?;
421 Ok(WebhookEvent {
422 id: field(&event, "id")?.to_owned(),
423 kind: field(&event, "type")?.to_owned(),
424 data: event
425 .get("data")
426 .and_then(|data| data.get("object"))
427 .cloned()
428 .unwrap_or(Value::Null),
429 })
430 }
431}
432
433fn parse_signature_header(header: &str) -> Result<(i64, Vec<String>), PaymentsError> {
436 let mut timestamp = None;
437 let mut signatures = Vec::new();
438 for part in header.split(',') {
439 let Some((key, value)) = part.split_once('=') else {
440 continue;
441 };
442 match key.trim() {
443 "t" => timestamp = value.trim().parse::<i64>().ok(),
444 "v1" => signatures.push(value.trim().to_owned()),
445 _ => {}
446 }
447 }
448 let timestamp = timestamp
449 .ok_or_else(|| PaymentsError::SignatureInvalid("no timestamp in header".to_owned()))?;
450 if signatures.is_empty() {
451 return Err(PaymentsError::SignatureInvalid(
452 "no v1 signature in header".to_owned(),
453 ));
454 }
455 Ok((timestamp, signatures))
456}
457
458fn hex_decode(input: &str) -> Option<Vec<u8>> {
460 if !input.len().is_multiple_of(2) {
461 return None;
462 }
463 let mut out = Vec::with_capacity(input.len() / 2);
464 let bytes = input.as_bytes();
465 let mut index = 0;
466 while index < bytes.len() {
467 let high = (bytes[index] as char).to_digit(16)?;
468 let low = (bytes[index + 1] as char).to_digit(16)?;
469 out.push(u8::try_from(high * 16 + low).ok()?);
470 index += 2;
471 }
472 Some(out)
473}