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::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
44/// How much clock skew a webhook timestamp may have before it is rejected.
45/// Stripe recommends five minutes.
46pub const WEBHOOK_TOLERANCE: Duration = Duration::from_secs(300);
47
48/// [`Payments`] over the Stripe REST API.
49pub 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    /// The `whsec_...` signing secret for webhook verification; may be empty
63    /// when webhooks are not yet configured (then [`Payments::verify_webhook`]
64    /// reports `NotConfigured`).
65    webhook_secret: String,
66    base_url: String,
67}
68
69impl Stripe {
70    /// A live adapter. `secret_key` is the `sk_...` API key; `webhook_secret`
71    /// is the `whsec_...` endpoint signing secret (pass an empty string if
72    /// webhooks are not configured yet).
73    #[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    /// A degraded adapter that reports [`PaymentsError::NotConfigured`] without
92    /// any network call — for a venture with no Stripe keys set.
93    #[must_use]
94    pub fn not_configured() -> Self {
95        Self {
96            inner: Inner::NotConfigured,
97        }
98    }
99
100    /// Overrides the API base URL (tests point this at a scripted client's
101    /// expected host; production uses `https://api.stripe.com`).
102    #[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/// Accumulates `application/x-www-form-urlencoded` fields, Stripe's request
119/// encoding, with the bracket notation Stripe uses for nested objects.
120#[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
154/// Percent-encodes for `application/x-www-form-urlencoded`: unreserved bytes
155/// pass through, everything else (space included, as `%20`) is escaped.
156fn 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    /// `POST {base}/v1/{path}` with the API key, an idempotency key, and a
174    /// form body; returns the parsed JSON on `2xx`, else a mapped error.
175    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    /// The raw `POST` above with no error mapping: the caller inspects the
191    /// status and body itself. Used where a specific non-`2xx` is not an
192    /// error — Stripe's duplicate meter event is a success in disguise.
193    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    /// `GET {base}/v1/{path}` with the API key and any query parameters; the
220    /// query values are percent-encoded. Returns the parsed JSON on `2xx`,
221    /// else a mapped error.
222    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
256/// Stripe's `error.code` and `error.message` from an error body, either of
257/// which may be absent.
258fn 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
272/// Maps a non-2xx Stripe response to a [`PaymentsError`]: `429`/`5xx` are
273/// retryable, everything else is a request that will not succeed unchanged.
274/// The detail carries Stripe's `error.code` and `error.message` when present.
275fn 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
291/// Reads a required string field from a Stripe object.
292fn 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
299/// A Stripe id used as a URL path segment (a `sub_...`/`cus_...` id: ASCII
300/// alphanumerics and `_`). Anything else — an empty string, a `/`, `?`, `#`,
301/// whitespace — is refused rather than allowed to change the path.
302fn 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
310/// The customer id from a subscription's `customer` field, which Stripe
311/// returns as a bare id string unless it was expanded into an object.
312fn 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
328/// The string metadata a Stripe object carries, as a `BTreeMap`; absent
329/// metadata is empty, and a non-string value is skipped rather than failing
330/// the whole object.
331fn 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
346/// Reads a Stripe `subscription` object into the port's [`Subscription`].
347fn 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    // Stripe moved `current_period_end` from the subscription onto its items
368    // in API version 2025-03-31 ("basil"). Read the top level first and fall
369    // back to the first item, so either shape parses without repinning.
370    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        // `status=all` includes canceled subscriptions, which Stripe's list
491        // omits by default — a reconciliation poll must see ended ones too.
492        // `limit=100` is Stripe's maximum page; follow `has_more` with
493        // `starting_after` until the list is exhausted.
494        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                // A page that hands back the cursor it was given is not
523                // progress: re-sending it would repeat one request forever and
524                // `list_subscriptions` would never return.
525                (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        // Reuse an existing account, or create an Express account first.
546        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        // Reject a stale (or future) timestamp before the constant-time check.
636        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        // HMAC-SHA256 over `{timestamp}.{body}`, compared constant-time against
644        // each `v1` the header carried. No early exit: every candidate is
645        // decoded and compared, the `Choice`s are OR-ed, and the verdict is
646        // converted only once at the end — so how long this takes says nothing
647        // about how much of a wrong signature was right, nor which candidate
648        // (if any) matched. A candidate of the wrong length simply compares
649        // unequal (`ct_eq` is false on a length mismatch).
650        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        // A shape error is the caller's, not Stripe's: reject before any
685        // network call, so a misconfigured tick cannot spray requests.
686        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        // The identifier is also the Idempotency-Key: an exact retry within
704        // Stripe's 24-hour idempotency window returns the first response.
705        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        // Stripe documents a duplicate identifier as `400 duplicate_meter_event`
719        // only; `409` is never a duplicate, just a concurrency conflict on the
720        // customer+meter, so a retry is safe rather than dead-lettering.
721        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        // A duplicate identifier is success, not failure: the window is
729        // already counted. Stripe answers `400 duplicate_meter_event`; when
730        // the body carries no code, fall back to a message that names both a
731        // duplicate and an identifier, so an unrelated `400` never becomes a
732        // silent success.
733        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
751/// Parses `Stripe-Signature: t=<unix>,v1=<hex>[,v1=<hex>]` into the timestamp
752/// and every `v1` scheme signature.
753fn 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
776/// Decodes a lowercase/uppercase hex string to bytes; `None` on any non-hex.
777fn 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}