Skip to main content

cratefield_adapter_stripe/
lib.rs

1//! `cratefield-adapter-stripe`: the [`Payments`] port over the Stripe REST API
2//! (issue #102). Uses the runtime's [`HttpClient`] and [`Clock`] ports — no
3//! vendor SDK — so the same adapter runs on Workers and natively.
4//!
5//! **Card data never crosses this adapter.** Every call creates or reads a
6//! Stripe object by id, or returns a hosted Stripe URL the browser is
7//! redirected to; card numbers are entered on Stripe's own pages. The harness
8//! holds Stripe identifiers, nothing more (see `docs/PAYMENTS.md`).
9//!
10//! **Degraded mode.** [`Stripe::not_configured`] reports
11//! [`PaymentsError::NotConfigured`] without any network call, so a venture with
12//! no Stripe keys still builds and runs.
13//!
14//! **Verification.** Request shaping, error mapping, and webhook signature
15//! verification (including a tampered signature and a stale timestamp) are unit
16//! tested here against a scripted `HttpClient`. The live path against Stripe is
17//! `needs-human` (issue #102 acceptance): it needs real test-mode keys, which
18//! do not live in the repo.
19#![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
40/// How much clock skew a webhook timestamp may have before it is rejected.
41/// Stripe recommends five minutes.
42pub const WEBHOOK_TOLERANCE: Duration = Duration::from_secs(300);
43
44/// [`Payments`] over the Stripe REST API.
45pub 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    /// The `whsec_...` signing secret for webhook verification; may be empty
59    /// when webhooks are not yet configured (then [`Payments::verify_webhook`]
60    /// reports `NotConfigured`).
61    webhook_secret: String,
62    base_url: String,
63}
64
65impl Stripe {
66    /// A live adapter. `secret_key` is the `sk_...` API key; `webhook_secret`
67    /// is the `whsec_...` endpoint signing secret (pass an empty string if
68    /// webhooks are not configured yet).
69    #[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    /// A degraded adapter that reports [`PaymentsError::NotConfigured`] without
88    /// any network call — for a venture with no Stripe keys set.
89    #[must_use]
90    pub fn not_configured() -> Self {
91        Self {
92            inner: Inner::NotConfigured,
93        }
94    }
95
96    /// Overrides the API base URL (tests point this at a scripted client's
97    /// expected host; production uses `https://api.stripe.com`).
98    #[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/// Accumulates `application/x-www-form-urlencoded` fields, Stripe's request
115/// encoding, with the bracket notation Stripe uses for nested objects.
116#[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
150/// Percent-encodes for `application/x-www-form-urlencoded`: unreserved bytes
151/// pass through, everything else (space included, as `%20`) is escaped.
152fn 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    /// `POST {base}/v1/{path}` with the API key, an idempotency key, and a
170    /// form body; returns the parsed JSON on `2xx`, else a mapped error.
171    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
205/// Maps a non-2xx Stripe response to a [`PaymentsError`]: `429`/`5xx` are
206/// retryable, everything else is a request that will not succeed unchanged.
207fn 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
226/// Reads a required string field from a Stripe object.
227fn 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        // Reuse an existing account, or create an Express account first.
304        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        // Reject a stale (or future) timestamp before the constant-time check.
394        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        // HMAC-SHA256 over `{timestamp}.{body}`, compared constant-time against
402        // each `v1` the header carried.
403        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
433/// Parses `Stripe-Signature: t=<unix>,v1=<hex>[,v1=<hex>]` into the timestamp
434/// and every `v1` scheme signature.
435fn 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
458/// Decodes a lowercase/uppercase hex string to bytes; `None` on any non-hex.
459fn 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}