Skip to main content

mollie_rs/
lib.rs

1//! Typed Mollie API client for Rust.
2//!
3//! Owned and maintained by Suits Finance B.V. This is an unofficial SDK: it is not
4//! affiliated with, endorsed by, or supported by Mollie B.V.
5//!
6//! The generated [`Client`] exposes one typed async method for every operation
7//! in the checked-in Mollie OpenAPI spec. [`MollieClient`] is the ergonomic
8//! facade for applications: it builds the HTTP client, sets authentication, and
9//! dereferences to [`Client`] so the full generated route surface remains
10//! available.
11//!
12//! # Examples
13//!
14//! ```rust,no_run
15//! use mollie_rs::{types, IntoMollieFuture, MollieClient, Money};
16//!
17//! # async fn create() -> Result<(), mollie_rs::MollieError> {
18//! let client = MollieClient::from_api_key("test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")?;
19//! let payment = mollie_rs::CreatePaymentRequired::new(
20//!     "Order #12345",
21//!     Money::new("EUR", "10.00")?,
22//!     "https://example.com/return",
23//! )?
24//! .into_payment_request();
25//!
26//! let response = client
27//!     .create_payment(None, &payment)
28//!     .into_mollie_result()
29//!     .await?;
30//! // When no sticky key is configured, a UUID v4 is generated and returned.
31//! let _key = response.idempotency_key();
32//! let _payment = response.into_inner();
33//! # Ok(())
34//! # }
35//! ```
36//!
37//! To reuse a key for retries of the same logical operation, bind it on the
38//! client (owned, no lifetime coupling to request bodies):
39//!
40//! ```rust,no_run
41//! # use mollie_rs::{CreatePaymentRequired, IntoMollieFuture, MollieClient, Money};
42//! # async fn retry() -> Result<(), mollie_rs::MollieError> {
43//! # let client = MollieClient::from_api_key("test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")?;
44//! # let payment = CreatePaymentRequired::new(
45//! #     "Order #12345",
46//! #     Money::new("EUR", "10.00")?,
47//! #     "https://example.com/return",
48//! # )?
49//! # .into_payment_request();
50//! let client = client.with_idempotency_key("6f7ef3e6-8c2f-4d1c-9f08-5ab7adf56c91");
51//! let _response = client.create_payment(None, &payment).into_mollie_result().await?;
52//! # Ok(())
53//! # }
54//! ```
55//!
56//! See `docs/api-overview.md` for the SDK capability map, `docs/route-coverage.md`
57//! for the generated route matrix, and `docs/contracts/` for public facade
58//! contracts.
59
60pub mod address;
61pub mod auth;
62#[cfg(test)]
63mod capabilities_fixture;
64pub mod client;
65pub mod contract_drift;
66pub mod country_code;
67pub mod create_payment;
68pub mod datetime;
69pub mod domain;
70pub mod empty;
71pub mod env;
72pub mod envelope;
73pub mod error;
74pub mod error_catalog;
75pub mod factory;
76pub mod hooks;
77pub mod idempotency;
78pub mod ids;
79pub mod integration;
80pub mod locale;
81pub mod metadata;
82pub mod money;
83pub mod nullable_field;
84pub mod open_enum;
85pub mod operation_safety;
86pub mod pagination;
87pub mod payment_method;
88pub mod phone_number;
89#[cfg(test)]
90mod postman_error_fixtures;
91pub mod provider_enums;
92pub mod response_limits;
93pub mod route_capabilities;
94/// Application tracing-subscriber helpers (`app-helpers` feature, default on).
95#[cfg(feature = "app-helpers")]
96pub mod tracing_config;
97pub mod transport;
98pub mod webhook;
99pub mod webhook_verify;
100pub mod write_requests;
101
102#[cfg(test)]
103mod property_tests;
104#[cfg(test)]
105mod secret_leak_tests;
106
107use reqwest::{Client as ReqwestClient, ClientBuilder as ReqwestClientBuilder};
108
109pub use address::{Address, POSTAL_CODE_OPTIONAL_COUNTRIES};
110pub use auth::{ApiKey, BasicAuth, Credential, OAuthAccessToken};
111pub use client::{MollieClient, MollieClientBuilder, DEFAULT_BASE_URL};
112pub use contract_drift::{
113    emit_contract_drift, global_contract_drift_observer, set_global_contract_drift_observer,
114    ContractDriftKind, ContractDriftObserver, ContractDriftScopeGuard, ContractDriftSignal,
115    NoopContractDriftObserver, SharedContractDriftObserver, CONTRACT_DRIFT_DETAIL_MAX_LEN,
116};
117pub use country_code::CountryCode;
118pub use create_payment::{
119    CreatePaymentRequired, PaymentDescription, RedirectUrl, PAYMENT_DESCRIPTION_MAX_LEN,
120};
121pub use datetime::{Date, DateTime};
122pub use domain::{
123    CapturesApi, ConnectBalanceTransfersApi, MandatesApi, OAuthApi, PaymentLinksApi, PaymentsApi,
124    PayoutsApi, RefundsApi, SessionsApi, SubscriptionsApi, TerminalsApi, TransferClientSignature,
125    TransfersApi, UnmatchedCreditTransfersApi, VerifyPayeeApi, WebhooksApi,
126};
127pub use empty::EmptyResponse;
128pub use env::{
129    load_dotenv, load_dotenv_from, var, var_optional, var_os, MOLLIE_API_KEY_ENV,
130    MOLLIE_BASE_URL_ENV, MOLLIE_OAUTH_ACCESS_TOKEN_ENV, MOLLIE_OAUTH_CLIENT_ID_ENV,
131    MOLLIE_OAUTH_CLIENT_SECRET_ENV,
132};
133pub use envelope::{
134    GeneratedMollieResult, IntoMollieFuture, IntoMollieResult, MollieEnvelope, MollieResponse,
135    ResponseEnvelope, ResponseValueExt,
136};
137pub use error::{MollieError, MollieResult};
138pub use error_catalog::{
139    MollieErrorCatalogEntry, MollieErrorCode, MollieErrorEnvelope, MollieErrorKey,
140    MollieSuccessCatalogEntry, MollieSuccessCode, MollieSuccessEnvelope, MollieSuccessKey,
141};
142pub use hooks::{NoopHook, RequestContext, RequestHook, SharedRequestHook};
143pub use idempotency::{IdempotencyKey, IDEMPOTENCY_KEY_MAX_LEN};
144pub use ids::{
145    BalanceId, CaptureId, ChargebackId, CustomerId, MandateId, PaymentId, PaymentLinkId, ProfileId,
146    RefundId, SalesInvoiceId, SettlementId, SubscriptionId, TerminalId,
147};
148pub use integration::{
149    ClaimResult, EventStoreReplayAdapter, PaymentStateRefetcher, WebhookDispatcher,
150    WebhookEventStore, WebhookReplayStore,
151};
152pub use locale::Locale;
153pub use metadata::{ErrorResponseContext, ResponseMetadata, MAX_RETAINED_BODY_BYTES};
154pub use money::{
155    AmountValue, ApplicationFee, ApplicationFeeDescription, Currency, Money,
156    APPLICATION_FEE_DESCRIPTION_MAX_LEN,
157};
158pub use nullable_field::{is_omitted as nullable_field_is_omitted, NullableField};
159pub use open_enum::{OpenEnum, OpenEnumError, OPEN_ENUM_MAX_RAW_LEN};
160pub use operation_safety::{
161    all_operation_safety_profiles, high_risk_coverage, operation_safety_profile, AuthClass,
162    IdempotencyClass, MutationClass, OperationExposure, OperationRisk, OperationSafetyProfile,
163    PaginationPolicy, ProfileScope, TestmodePolicy, HIGH_RISK_WRITE_OPERATION_IDS,
164    PAYMENT_CAPABILITY_MUTATION_OPERATION_IDS,
165};
166pub use pagination::{
167    AsyncPaginator, ItemStream, Page, PageCursor, PaginationGuard, DEFAULT_PAGE_LIMIT,
168    MAX_PAGE_LIMIT,
169};
170pub use payment_method::PaymentMethod;
171pub use phone_number::PhoneNumber;
172pub use provider_enums::{
173    parse_payment_status, payment_status_from_generated, payment_status_to_generated,
174    PaymentStatusKnown, PaymentStatusValue,
175};
176pub use response_limits::{ResponseLimits, DEFAULT_MAX_ERROR_BODY_BYTES, DEFAULT_MAX_JSON_BYTES};
177pub use route_capabilities::{
178    retry_class_for_operation, route_capability, RouteAccess, RouteCapability, ROUTE_CAPABILITIES,
179};
180#[cfg(feature = "app-helpers")]
181pub use tracing_config::{
182    init_tracing, init_tracing_with_filter, try_init_tracing, try_init_tracing_with_filter,
183};
184pub use transport::{compute_backoff, DeliveryOutcome, RetryClass, RetryPolicy};
185pub use webhook::{WebhookNotification, WebhookUrl};
186pub use webhook_verify::{
187    compute_mollie_signature_hex, VerifiedWebhook, WebhookSigningSecret, WebhookVerifier,
188    WebhookVerifyFailure, DEFAULT_MAX_WEBHOOK_BODY_BYTES, MOLLIE_SIGNATURE_HEADER,
189};
190pub use write_requests::{
191    ConnectBalanceTransferParty, CreateCaptureRequired, CreateConnectBalanceTransferRequired,
192    CreatePaymentLinkRequired, CreatePayoutRequired, CreateRefundRequired,
193    CreateSepaMandateRequired, CreateSubscriptionRequired, CreateTransferRequired,
194    UpdatePaymentRequired, VerifyPayeeRequired,
195};
196
197/// Re-export of the `tracing` crate for application instrumentation.
198pub use tracing;
199/// Re-export of the `tracing-subscriber` crate used by [`init_tracing`].
200///
201/// Only available with the default `app-helpers` feature.
202#[cfg(feature = "app-helpers")]
203pub use tracing_subscriber;
204
205use progenitor_client::ClientHooks;
206#[allow(unused_imports)]
207pub use progenitor_client::{ByteStream, ClientInfo, Error, ResponseValue};
208
209/// Generated Mollie API route groups (inherent methods on [`Client`]).
210pub mod routes;
211/// Generated OpenAPI types for request and response bodies.
212pub mod types;
213
214/// Low-level typed Mollie API client (OpenAPI-generated route surface).
215///
216/// Prefer [`MollieClient`] for application construction (base URL, auth, and
217/// HTTP defaults). Generated route methods live as inherent methods on this
218/// type (and therefore also on [`MollieClient`] via `Deref`).
219///
220/// Client state shared by generated routes:
221///
222/// - **Idempotency**: no sticky key (default) → UUID v4 per request;
223///   [`Self::with_idempotency_key`] reuses an owned key for retries of the same
224///   logical operation. The resolved key is always sent and returned on the
225///   response envelope ([`ResponseEnvelope::idempotency_key`] /
226///   [`ResponseValueExt::idempotency_key`]).
227/// - **Test mode**: [`Self::with_testmode`] sets a sticky `testmode` query for
228///   operations that document it (typical for OAuth org tokens). Default `None`
229///   leaves the credential mode unchanged.
230///
231/// Version: 1.0.0
232#[derive(Clone)]
233pub struct Client {
234    /// Configured API base URL (scheme, host, optional path stem).
235    pub(crate) baseurl: String,
236    /// Shared `reqwest` HTTP client used by generated route methods.
237    pub(crate) client: ReqwestClient,
238    /// Sticky idempotency key for outbound requests.
239    ///
240    /// When `None` or empty, each request generates a fresh UUID v4. Set via
241    /// [`Self::with_idempotency_key`] to reuse a key across retries of the same
242    /// logical operation. Do not reuse one key for unrelated operations.
243    ///
244    /// Prefer [`IdempotencyKey`] and a short-lived scoped client for one logical
245    /// write rather than leaving a sticky key for unrelated operations.
246    pub(crate) idempotency_key: Option<String>,
247    /// Sticky `testmode` query for routes that support it.
248    ///
249    /// When `None`, the query param is omitted and the credential mode applies.
250    /// Set via [`Self::with_testmode`] (common for OAuth access tokens that need
251    /// test entities). Body-level `testmode` fields on request types are separate.
252    pub(crate) testmode: Option<bool>,
253    /// Sticky default profile id for facades that support profile context.
254    ///
255    /// Generated route methods still accept explicit `profile_id` parameters;
256    /// operation arguments take precedence over this default when both are set
257    /// by application code.
258    pub(crate) profile_id: Option<String>,
259    /// Opt-in retry policy (disabled by default).
260    pub(crate) retry_policy: transport::RetryPolicy,
261    /// Optional request lifecycle hook (metrics / correlation / test doubles).
262    pub(crate) request_hook: Option<hooks::SharedRequestHook>,
263    /// Optional contract-drift observer (unknown enums / off-origin next links).
264    pub(crate) contract_drift_observer: Option<contract_drift::SharedContractDriftObserver>,
265    /// Request timeout retained so credential rebuilds can preserve it.
266    pub(crate) timeout: std::time::Duration,
267    /// Connect timeout retained so credential rebuilds can preserve it.
268    pub(crate) connect_timeout: std::time::Duration,
269    /// User-Agent string retained for credential rebuilds (no secrets).
270    pub(crate) user_agent: Option<String>,
271    /// Maximum buffered response body sizes for JSON and error decoding.
272    pub(crate) response_limits: ResponseLimits,
273}
274
275impl std::fmt::Debug for Client {
276    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
277        f.debug_struct("Client")
278            .field("baseurl", &self.baseurl)
279            .field(
280                "idempotency_key",
281                &self.idempotency_key.as_ref().map(|_| "<redacted>"),
282            )
283            .field("testmode", &self.testmode)
284            .field("profile_id", &self.profile_id)
285            .field("retry_policy", &self.retry_policy)
286            .field(
287                "request_hook",
288                &self.request_hook.as_ref().map(|_| "<hook>"),
289            )
290            .field(
291                "contract_drift_observer",
292                &self
293                    .contract_drift_observer
294                    .as_ref()
295                    .map(|_| "<contract_drift_observer>"),
296            )
297            .field("timeout", &self.timeout)
298            .field("connect_timeout", &self.connect_timeout)
299            .field("user_agent", &self.user_agent)
300            .field("response_limits", &self.response_limits)
301            .finish_non_exhaustive()
302    }
303}
304
305/// Construction, HTTP helpers, and request lifecycle for generated routes.
306impl Client {
307    /// Create a new client with default HTTP timeouts (15s connect and total).
308    ///
309    /// `baseurl` is the base URL provided to the internal `reqwest::Client`,
310    /// and should include a scheme and hostname, as well as port and a path
311    /// stem if applicable.
312    ///
313    /// # Errors
314    ///
315    /// Returns [`reqwest::Error`] when the default HTTP client cannot be built
316    /// (for example when the TLS backend fails to initialize).
317    ///
318    /// # Examples
319    ///
320    /// ```rust
321    /// use mollie_rs::{Client, DEFAULT_BASE_URL};
322    ///
323    /// let client = Client::new(DEFAULT_BASE_URL)?;
324    /// assert_eq!(client.baseurl(), DEFAULT_BASE_URL);
325    /// # Ok::<(), reqwest::Error>(())
326    /// ```
327    pub fn new(baseurl: &str) -> Result<Self, reqwest::Error> {
328        #[cfg(not(target_arch = "wasm32"))]
329        let client: ReqwestClientBuilder = {
330            let dur: std::time::Duration = ::std::time::Duration::from_secs(15u64);
331            ReqwestClientBuilder::new()
332                .connect_timeout(dur)
333                .timeout(dur)
334        };
335        #[cfg(target_arch = "wasm32")]
336        let client = ReqwestClientBuilder::new();
337        match client.build() {
338            Ok(http_client) => Ok(Self::new_with_client(baseurl, http_client)),
339            Err(error) => Err(error),
340        }
341    }
342
343    /// Construct a new client with an existing `reqwest::Client`,
344    /// allowing more control over its configuration.
345    ///
346    /// `baseurl` is the base URL provided to the internal `reqwest::Client`,
347    /// and should include a scheme and hostname, as well as port and a path
348    /// stem if applicable.
349    ///
350    /// # Examples
351    ///
352    /// ```rust
353    /// use mollie_rs::{Client, DEFAULT_BASE_URL};
354    ///
355    /// let http = reqwest::Client::new();
356    /// let client = Client::new_with_client(DEFAULT_BASE_URL, http);
357    /// assert_eq!(client.baseurl(), DEFAULT_BASE_URL);
358    /// ```
359    pub fn new_with_client(baseurl: &str, client: ReqwestClient) -> Self {
360        Self {
361            baseurl: baseurl.to_string(),
362            client,
363            idempotency_key: None,
364            testmode: None,
365            profile_id: None,
366            retry_policy: transport::RetryPolicy::disabled(),
367            request_hook: None,
368            contract_drift_observer: None,
369            // Defaults match historical Client::new (15s). Builders override.
370            timeout: std::time::Duration::from_secs(15),
371            connect_timeout: std::time::Duration::from_secs(15),
372            user_agent: None,
373            response_limits: ResponseLimits::default(),
374        }
375    }
376
377    /// Records HTTP timeout settings used when rebuilding credentials.
378    pub(crate) fn with_transport_timeouts(
379        mut self,
380        timeout: std::time::Duration,
381        connect_timeout: std::time::Duration,
382    ) -> Self {
383        self.timeout = timeout;
384        self.connect_timeout = connect_timeout;
385        self
386    }
387
388    /// Records the User-Agent for credential rebuilds.
389    pub(crate) fn with_user_agent_string(mut self, user_agent: impl Into<String>) -> Self {
390        self.user_agent = Some(user_agent.into());
391        self
392    }
393
394    /// Returns the configured request timeout.
395    pub fn timeout(&self) -> std::time::Duration {
396        self.timeout
397    }
398
399    /// Returns the configured connect timeout.
400    pub fn connect_timeout(&self) -> std::time::Duration {
401        self.connect_timeout
402    }
403
404    /// Returns the configured User-Agent, if known.
405    pub fn user_agent(&self) -> Option<&str> {
406        self.user_agent.as_deref()
407    }
408
409    /// Returns the configured response body limits.
410    pub fn response_limits(&self) -> ResponseLimits {
411        self.response_limits
412    }
413
414    /// Returns a client with custom response body buffering limits.
415    pub fn with_response_limits(mut self, limits: ResponseLimits) -> Self {
416        self.response_limits = limits;
417        self
418    }
419
420    /// Returns a client with the given retry policy (clones transport settings).
421    pub fn with_retry_policy(mut self, policy: transport::RetryPolicy) -> Self {
422        self.retry_policy = policy;
423        self
424    }
425
426    /// Returns the configured retry policy.
427    pub fn retry_policy(&self) -> &transport::RetryPolicy {
428        &self.retry_policy
429    }
430
431    /// Returns a client that sends the given sticky idempotency key on every
432    /// request until cleared.
433    ///
434    /// **Discouraged for long-lived clients:** a sticky key reused across
435    /// unrelated operations violates Mollie idempotency semantics. Prefer
436    /// [`IdempotencyKey`] with a short-lived clone via
437    /// [`MollieClient::with_idempotency`] for one logical write (and its
438    /// transport retries).
439    ///
440    /// Prefer this only for retries of the **same** logical operation.
441    /// Empty keys are ignored and treated as missing (UUID v4 is generated).
442    ///
443    /// # Examples
444    ///
445    /// ```rust
446    /// use mollie_rs::{Client, DEFAULT_BASE_URL};
447    ///
448    /// let client = Client::new(DEFAULT_BASE_URL).expect("default client")
449    ///     .with_idempotency_key("6f7ef3e6-8c2f-4d1c-9f08-5ab7adf56c91");
450    /// assert_eq!(
451    ///     client.idempotency_key(),
452    ///     Some("6f7ef3e6-8c2f-4d1c-9f08-5ab7adf56c91")
453    /// );
454    /// ```
455    pub fn with_idempotency_key(mut self, key: impl Into<String>) -> Self {
456        self.idempotency_key = Some(key.into());
457        self
458    }
459
460    /// Returns a cloned client with a sticky idempotency key, leaving `self`
461    /// unchanged.
462    ///
463    /// # Examples
464    ///
465    /// ```rust
466    /// use mollie_rs::{Client, DEFAULT_BASE_URL};
467    ///
468    /// let client = Client::new(DEFAULT_BASE_URL).expect("default client");
469    /// let scoped = client.with_idempotency_key_ref("retry-key");
470    /// assert!(client.idempotency_key().is_none());
471    /// assert_eq!(scoped.idempotency_key(), Some("retry-key"));
472    /// ```
473    pub fn with_idempotency_key_ref(&self, key: impl Into<String>) -> Self {
474        self.clone().with_idempotency_key(key)
475    }
476
477    /// Clears any sticky idempotency key so subsequent requests generate a UUID
478    /// v4.
479    ///
480    /// # Examples
481    ///
482    /// ```rust
483    /// use mollie_rs::{Client, DEFAULT_BASE_URL};
484    ///
485    /// let client = Client::new(DEFAULT_BASE_URL).expect("default client")
486    ///     .with_idempotency_key("retry-key")
487    ///     .clear_idempotency_key();
488    /// assert!(client.idempotency_key().is_none());
489    /// ```
490    pub fn clear_idempotency_key(mut self) -> Self {
491        self.idempotency_key = None;
492        self
493    }
494
495    /// Returns the configured sticky idempotency key, if any.
496    ///
497    /// This is the key stored on the client, not necessarily the key that was
498    /// last sent (auto-generated keys are not stored). Read the key that was
499    /// sent from the response envelope after a call.
500    pub fn idempotency_key(&self) -> Option<&str> {
501        self.idempotency_key.as_deref()
502    }
503
504    /// Returns a client that sends the sticky `testmode` query on routes that
505    /// support it.
506    ///
507    /// Prefer this for organization-level OAuth credentials that need test
508    /// entities. API keys created for live or test mode usually leave the
509    /// default (`None`) so the credential decides.
510    ///
511    /// # Examples
512    ///
513    /// ```rust
514    /// use mollie_rs::{Client, DEFAULT_BASE_URL};
515    ///
516    /// let client = Client::new(DEFAULT_BASE_URL).expect("default client").with_testmode(true);
517    /// assert_eq!(client.testmode(), Some(true));
518    /// ```
519    pub fn with_testmode(mut self, testmode: bool) -> Self {
520        self.testmode = Some(testmode);
521        self
522    }
523
524    /// Returns a cloned client with sticky `testmode`, leaving `self` unchanged.
525    ///
526    /// # Examples
527    ///
528    /// ```rust
529    /// use mollie_rs::{Client, DEFAULT_BASE_URL};
530    ///
531    /// let client = Client::new(DEFAULT_BASE_URL).expect("default client");
532    /// let scoped = client.with_testmode_ref(true);
533    /// assert!(client.testmode().is_none());
534    /// assert_eq!(scoped.testmode(), Some(true));
535    /// ```
536    pub fn with_testmode_ref(&self, testmode: bool) -> Self {
537        self.clone().with_testmode(testmode)
538    }
539
540    /// Clears sticky `testmode` so supporting routes omit the query param.
541    ///
542    /// # Examples
543    ///
544    /// ```rust
545    /// use mollie_rs::{Client, DEFAULT_BASE_URL};
546    ///
547    /// let client = Client::new(DEFAULT_BASE_URL).expect("default client")
548    ///     .with_testmode(true)
549    ///     .clear_testmode();
550    /// assert!(client.testmode().is_none());
551    /// ```
552    pub fn clear_testmode(mut self) -> Self {
553        self.testmode = None;
554        self
555    }
556
557    /// Returns the configured sticky `testmode` value, if any.
558    ///
559    /// Generated routes that document the query param pass this value through
560    /// `QueryParam` (so `None` omits the parameter).
561    pub fn testmode(&self) -> Option<bool> {
562        self.testmode
563    }
564
565    /// Returns a client with a sticky default profile id for facades.
566    ///
567    /// Does not rewrite generated OpenAPI method signatures; domain facades and
568    /// application code should prefer this default when an operation-level
569    /// profile override is omitted.
570    pub fn with_profile_id(mut self, profile_id: impl Into<String>) -> Self {
571        self.profile_id = Some(profile_id.into());
572        self
573    }
574
575    /// Clears any sticky default profile id.
576    pub fn clear_profile_id(mut self) -> Self {
577        self.profile_id = None;
578        self
579    }
580
581    /// Returns the sticky default profile id, if any.
582    pub fn profile_id(&self) -> Option<&str> {
583        self.profile_id.as_deref()
584    }
585
586    /// Attaches a shared request lifecycle hook.
587    pub fn with_request_hook(mut self, hook: hooks::SharedRequestHook) -> Self {
588        self.request_hook = Some(hook);
589        self
590    }
591
592    /// Returns the configured request hook, if any.
593    pub fn request_hook(&self) -> Option<&hooks::SharedRequestHook> {
594        self.request_hook.as_ref()
595    }
596
597    /// Attaches a shared contract-drift observer (TEL-001).
598    pub fn with_contract_drift_observer(
599        mut self,
600        observer: contract_drift::SharedContractDriftObserver,
601    ) -> Self {
602        self.contract_drift_observer = Some(observer);
603        self
604    }
605
606    /// Returns the configured contract-drift observer, if any.
607    pub fn contract_drift_observer(&self) -> Option<&contract_drift::SharedContractDriftObserver> {
608        self.contract_drift_observer.as_ref()
609    }
610
611    /// Rejects sticky test mode for an operation that Mollie exposes only in
612    /// live mode.
613    ///
614    /// Business-operation routes such as balances, settlements, and invoices
615    /// do not support the `testmode` query parameter. Keeping this check in
616    /// the generated route lifecycle prevents a caller from accidentally
617    /// making a live request after asking for test mode.
618    #[allow(clippy::result_large_err)]
619    pub(crate) fn reject_testmode_for(
620        &self,
621        operation: &str,
622    ) -> Result<(), Error<types::ErrorResponse>> {
623        if self.testmode.is_some() {
624            return Err(Error::InvalidRequest(format!(
625                "testmode is not supported for the `{operation}` operation"
626            )));
627        }
628
629        Ok(())
630    }
631
632    /// Returns the configured base URL used for generated route requests.
633    ///
634    /// # Examples
635    ///
636    /// ```rust
637    /// use mollie_rs::{Client, DEFAULT_BASE_URL};
638    ///
639    /// let client = Client::new(DEFAULT_BASE_URL).expect("default client");
640    /// assert_eq!(client.baseurl(), DEFAULT_BASE_URL);
641    /// ```
642    pub fn baseurl(&self) -> &str {
643        &self.baseurl
644    }
645
646    /// Returns the underlying HTTP client.
647    ///
648    /// # Examples
649    ///
650    /// ```rust
651    /// use mollie_rs::{Client, DEFAULT_BASE_URL};
652    ///
653    /// let client = Client::new(DEFAULT_BASE_URL).expect("default client");
654    /// let _http_client = client.http_client();
655    /// ```
656    pub fn http_client(&self) -> &ReqwestClient {
657        &self.client
658    }
659
660    /// Join a generated API path onto the configured base URL.
661    ///
662    /// # Arguments
663    ///
664    /// * `path` — absolute API path such as `/payments` (no host).
665    ///
666    /// # Returns
667    ///
668    /// Full request URL string used by generated route methods.
669    pub(crate) fn endpoint(&self, path: impl ::std::fmt::Display) -> String {
670        let path = path.to_string();
671        // OAuth token endpoints live on the API host root (`/oauth2/...`), not
672        // under the `/v2` resource stem used by DEFAULT_BASE_URL.
673        if path.starts_with("/oauth2/") {
674            if let Ok(mut url) = ::reqwest::Url::parse(&self.baseurl) {
675                url.set_path(&path);
676                url.set_query(None);
677                url.set_fragment(None);
678                return url.to_string();
679            }
680        }
681        format!("{}{}", self.baseurl, path)
682    }
683
684    /// Build a request with the common generated-route headers applied.
685    ///
686    /// Always sends an `Idempotency-Key` header. Resolution uses client state
687    /// ([`Self::idempotency_key`]): a non-empty sticky key is reused; otherwise
688    /// a UUID v4 is generated. The resolved key is returned so generated routes
689    /// can attach it to the response envelope.
690    ///
691    /// # Arguments
692    ///
693    /// * `method` — HTTP method for the route.
694    /// * `url` — absolute request URL (typically from [`Self::endpoint`]).
695    ///
696    /// # Returns
697    ///
698    /// A tuple of `(RequestBuilder, resolved_idempotency_key)`. The string is
699    /// always non-empty and is the exact value sent as `Idempotency-Key`.
700    ///
701    /// # Errors
702    ///
703    /// Returns [`reqwest::header::InvalidHeaderValue`] when the resolved key
704    /// cannot be encoded as an HTTP header value.
705    pub(crate) fn request(
706        &self,
707        method: ::reqwest::Method,
708        url: String,
709    ) -> Result<(::reqwest::RequestBuilder, String), ::reqwest::header::InvalidHeaderValue> {
710        let resolved_key: String = match self.idempotency_key.as_deref() {
711            Some(value) if !value.is_empty() => value.to_string(),
712            _ => uuid::Uuid::new_v4().to_string(),
713        };
714
715        let mut headers: reqwest::header::HeaderMap =
716            ::reqwest::header::HeaderMap::with_capacity(2usize);
717        headers.append(
718            ::reqwest::header::HeaderName::from_static("api-version"),
719            ::reqwest::header::HeaderValue::from_static(Self::api_version()),
720        );
721        headers.append("idempotency-key", resolved_key.as_str().try_into()?);
722
723        Ok((
724            self.client
725                .request(method, url)
726                .header(
727                    ::reqwest::header::ACCEPT,
728                    ::reqwest::header::HeaderValue::from_static("application/json"),
729                )
730                .headers(headers),
731            resolved_key,
732        ))
733    }
734
735    /// Execute a request through the generated client hook lifecycle.
736    ///
737    /// Runs `pre` hooks, performs the HTTP call, then runs `post` hooks from
738    /// [`ClientHooks`].
739    ///
740    /// # Arguments
741    ///
742    /// * `request` — fully built `reqwest` request (method, URL, headers, body).
743    /// * `operation` — generated operation id metadata for hooks and tracing.
744    ///
745    /// # Errors
746    ///
747    /// Propagates hook failures and transport errors as [`Error`].
748    ///
749    /// When [`Self::retry_policy`] is enabled, retries transient failures for
750    /// safe reads, and for writes **only** when a sticky/caller-bound
751    /// idempotency key is set on the client. Auto-generated per-request keys
752    /// alone do **not** enable write retries. Retries require a cloneable body.
753    ///
754    /// Retry classification prefers [`route_capabilities::route_capability`] for
755    /// the operation id and falls back to HTTP method classification.
756    ///
757    /// The retry budget ([`RetryPolicy::total_deadline`]) limits scheduling of
758    /// further attempts and backoff. When the budget is exhausted the SDK
759    /// returns the **last attempt’s result** and does **not** issue an extra
760    /// leftover request.
761    pub(crate) async fn send<E>(
762        &self,
763        mut request: ::reqwest::Request,
764        operation: routes::Operation,
765    ) -> Result<::reqwest::Response, Error<E>> {
766        let info = operation.info();
767        self.pre(&mut request, &info).await?;
768
769        let policy = &self.retry_policy;
770        // Registry is source of truth; method fallback never upgrades writes.
771        let class = route_capabilities::retry_class_for_operation(
772            operation.id(),
773            request.method().as_str(),
774        );
775        let has_sticky = self
776            .idempotency_key
777            .as_ref()
778            .is_some_and(|key| !key.is_empty());
779        let may_retry = policy.allows(class, has_sticky);
780        let max_attempts = if may_retry {
781            policy.max_attempts.max(1)
782        } else {
783            1
784        };
785        let started = std::time::Instant::now();
786        let retry_budget = policy.retry_budget();
787
788        for attempt in 1..=max_attempts {
789            // Invariant: remaining budget checked → attempt begins within budget
790            // or no request is sent. Never a leftover send after budget exit.
791            if attempt > 1 && started.elapsed() >= retry_budget {
792                return Err(Error::InvalidRequest(format!(
793                    "retry budget exhausted for operation `{}` (no further attempt sent)",
794                    operation.id()
795                )));
796            }
797
798            let method = request.method().as_str().to_string();
799            let url_redacted = redact_url_for_hooks(request.url());
800            let hook_ctx = hooks::RequestContext {
801                operation: operation.id(),
802                method,
803                url_redacted,
804                attempt,
805                has_sticky_idempotency: has_sticky,
806                profile_id: self.profile_id.clone(),
807                testmode: self.testmode,
808            };
809            let _drift_scope = contract_drift::ContractDriftScopeGuard::enter(
810                operation.id(),
811                self.contract_drift_observer.clone(),
812            );
813            if let Some(hook) = self.request_hook.as_ref() {
814                hook.before_request(&hook_ctx, &mut request);
815            }
816
817            let is_last = attempt == max_attempts;
818            let request_for_attempt = if is_last {
819                std::mem::replace(
820                    &mut request,
821                    ::reqwest::Request::new(
822                        ::reqwest::Method::GET,
823                        ::reqwest::Url::parse("http://127.0.0.1/").expect("static url"),
824                    ),
825                )
826            } else if let Some(cloned) = request.try_clone() {
827                cloned
828            } else {
829                // Non-cloneable body: single attempt only.
830                let result = self.exec(request, &info).await;
831                self.post(&result, &info).await?;
832                return Ok(result?);
833            };
834
835            let result = self.exec(request_for_attempt, &info).await;
836
837            if !is_last {
838                // Delivery-aware retry (INV-DELIV-01 / INV-WRITE-01):
839                // NotSent and Unknown may retry only when policy+class+sticky allow.
840                // Rejected/Succeeded never auto-retry. Timeout is Unknown, not
841                // "safe connection failure".
842                let should_retry = match &result {
843                    Ok(response) => {
844                        let outcome = transport::classify_http_status(response.status());
845                        transport::should_auto_retry(outcome, class, has_sticky, policy)
846                            && transport::is_transient_http_status(response.status())
847                    }
848                    Err(err) => {
849                        let outcome = transport::classify_reqwest_error(err);
850                        transport::should_auto_retry(outcome, class, has_sticky, policy)
851                    }
852                };
853
854                if should_retry {
855                    let retry_after = result.as_ref().ok().and_then(|response| {
856                        response
857                            .headers()
858                            .get("retry-after")
859                            .and_then(|v| v.to_str().ok())
860                            .and_then(|s| s.parse::<u64>().ok())
861                            .map(std::time::Duration::from_secs)
862                    });
863                    let delay = transport::compute_backoff(policy, attempt + 1, retry_after);
864                    // If backoff would push past the budget, return this attempt
865                    // instead of sleeping and sending another leftover request.
866                    if started.elapsed().saturating_add(delay) >= retry_budget {
867                        tracing::debug!(
868                            attempt,
869                            operation = operation.id(),
870                            budget_ms = retry_budget.as_millis() as u64,
871                            "retry budget exhausted before backoff; returning last attempt"
872                        );
873                        self.post(&result, &info).await?;
874                        return Ok(result?);
875                    }
876                    tracing::debug!(
877                        attempt,
878                        max_attempts,
879                        delay_ms = delay.as_millis() as u64,
880                        has_sticky_idempotency = has_sticky,
881                        operation = operation.id(),
882                        retry_class = ?class,
883                        "retrying Mollie HTTP request"
884                    );
885                    let _ = result;
886                    tokio::time::sleep(delay).await;
887                    continue;
888                }
889            }
890
891            if let (Some(hook), Ok(response)) = (self.request_hook.as_ref(), result.as_ref()) {
892                let metadata = crate::metadata::ResponseMetadata::from_status_and_headers(
893                    response.status(),
894                    response.headers(),
895                )
896                .with_attempt(attempt);
897                hook.after_response(&hook_ctx, &metadata);
898            }
899
900            self.post(&result, &info).await?;
901            return Ok(result?);
902        }
903
904        Err(Error::InvalidRequest(format!(
905            "retry budget exhausted for operation `{}`",
906            operation.id()
907        )))
908    }
909}
910
911/// Redacts query strings for hook/logging surfaces.
912///
913/// Mollie resource paths rarely put secrets in the query; when a query is
914/// present it is replaced with a marker so credentials never appear in hooks.
915fn redact_url_for_hooks(url: &reqwest::Url) -> String {
916    let mut redacted = url.clone();
917    if redacted.query().is_some() {
918        redacted.set_query(Some("<redacted>"));
919    }
920    redacted.to_string()
921}
922
923/// [`ClientInfo`] implementation used by progenitor-generated request helpers.
924impl ClientInfo<()> for Client {
925    /// Returns the OpenAPI / client API version string advertised to hooks.
926    fn api_version() -> &'static str {
927        "1.0.0"
928    }
929
930    /// Returns the configured base URL for this client instance.
931    fn baseurl(&self) -> &str {
932        self.baseurl.as_str()
933    }
934
935    /// Returns the shared `reqwest` client.
936    fn client(&self) -> &reqwest::Client {
937        &self.client
938    }
939
940    /// Returns the empty inner context unit value for this client.
941    fn inner(&self) -> &() {
942        &()
943    }
944}
945
946/// Default [`ClientHooks`] implementation (no custom pre/post hooks).
947impl ClientHooks<()> for &Client {}
948
949/// Convenience re-exports of the types most applications import together.
950///
951/// Includes client construction, credentials, money helpers, response
952/// envelopes, and [`ResponseValueExt`] for reading resolved idempotency keys.
953pub mod prelude {
954    #[allow(unused_imports)]
955    pub use super::{
956        load_dotenv, load_dotenv_from, var, var_optional, Address, AmountValue, ApiKey,
957        ApplicationFee, ApplicationFeeDescription, BasicAuth, Client, CountryCode,
958        CreatePaymentRequired, Credential, Currency, Date, DateTime, GeneratedMollieResult,
959        IntoMollieFuture, IntoMollieResult, Locale, MollieClient, MollieClientBuilder,
960        MollieEnvelope, MollieError, MollieErrorCatalogEntry, MollieErrorEnvelope, MollieErrorKey,
961        MollieResponse, MollieResult, MollieSuccessEnvelope, MollieSuccessKey, Money,
962        OAuthAccessToken, PaymentDescription, PaymentId, PaymentMethod, PhoneNumber, ProfileId,
963        RedirectUrl, ResponseEnvelope, ResponseMetadata, ResponseValueExt, WebhookNotification,
964        WebhookUrl, APPLICATION_FEE_DESCRIPTION_MAX_LEN, DEFAULT_BASE_URL, MOLLIE_API_KEY_ENV,
965        MOLLIE_BASE_URL_ENV, MOLLIE_OAUTH_ACCESS_TOKEN_ENV, MOLLIE_OAUTH_CLIENT_ID_ENV,
966        MOLLIE_OAUTH_CLIENT_SECRET_ENV,
967    };
968
969    #[cfg(feature = "app-helpers")]
970    #[allow(unused_imports)]
971    pub use super::{
972        init_tracing, init_tracing_with_filter, try_init_tracing, try_init_tracing_with_filter,
973    };
974}
975
976/// Unit tests for client request helpers and response-envelope idempotency.
977#[cfg(test)]
978mod tests {
979    use super::{
980        Client, Error, ResponseEnvelope, ResponseValue, ResponseValueExt, DEFAULT_BASE_URL,
981    };
982    use reqwest::StatusCode;
983
984    /// Asserts `key` is a non-empty UUID v4 and not equal to any forbidden value.
985    fn assert_generated_uuid_v4_idempotency_key(key: &str, forbidden: &[&str]) {
986        assert!(
987            !key.is_empty(),
988            "resolved idempotency key must be non-empty"
989        );
990        for bad in forbidden {
991            assert_ne!(
992                key, *bad,
993                "resolved key must not reuse invalid sticky value {bad:?}"
994            );
995        }
996        let parsed = uuid::Uuid::parse_str(key)
997            .unwrap_or_else(|err| panic!("expected UUID, got {key:?}: {err}"));
998        assert_eq!(
999            parsed.get_version(),
1000            Some(uuid::Version::Random),
1001            "expected UUID v4 (random), got version {:?} for {key}",
1002            parsed.get_version()
1003        );
1004    }
1005
1006    /// Missing / blank sticky keys (and cleared keys) never become the wire value;
1007    /// `request` always resolves a fresh UUID v4 instead.
1008    #[test]
1009    fn request_generates_uuid_v4_for_missing_or_blank_idempotency_keys() {
1010        // Default client: no sticky key (`None`).
1011        let none_client = Client::new(DEFAULT_BASE_URL).expect("default client");
1012        assert!(none_client.idempotency_key().is_none());
1013        let (_builder, key_none) = none_client
1014            .request(reqwest::Method::GET, none_client.endpoint("/payments"))
1015            .expect("request should build");
1016        assert_generated_uuid_v4_idempotency_key(&key_none, &["", "None", "null"]);
1017
1018        // Explicit empty string sticky key must not be sent.
1019        let empty_client = Client::new(DEFAULT_BASE_URL)
1020            .expect("default client")
1021            .with_idempotency_key("");
1022        assert_eq!(empty_client.idempotency_key(), Some(""));
1023        let (_builder, key_empty) = empty_client
1024            .request(reqwest::Method::POST, empty_client.endpoint("/payments"))
1025            .expect("request should build");
1026        assert_generated_uuid_v4_idempotency_key(&key_empty, &["", "None", "null"]);
1027        assert_ne!(
1028            key_empty, key_none,
1029            "auto-generated keys should not collide across independent requests"
1030        );
1031
1032        // Empty string via clone helper.
1033        let empty_ref_client: Client = Client::new(DEFAULT_BASE_URL)
1034            .expect("default client")
1035            .with_idempotency_key_ref("");
1036        assert_eq!(empty_ref_client.idempotency_key(), Some(""));
1037        let (_builder, key_empty_ref) = empty_ref_client
1038            .request(reqwest::Method::GET, empty_ref_client.endpoint("/payments"))
1039            .expect("request should build");
1040        assert_generated_uuid_v4_idempotency_key(&key_empty_ref, &[""]);
1041
1042        // Cleared sticky key restores auto generation (stored state is `None`).
1043        let cleared_client: Client = Client::new(DEFAULT_BASE_URL)
1044            .expect("default client")
1045            .with_idempotency_key("sticky-should-not-be-used")
1046            .clear_idempotency_key();
1047        assert!(cleared_client.idempotency_key().is_none());
1048        let (_builder, key_cleared) = cleared_client
1049            .request(reqwest::Method::GET, cleared_client.endpoint("/payments"))
1050            .expect("request should build");
1051        assert_generated_uuid_v4_idempotency_key(
1052            &key_cleared,
1053            &["", "sticky-should-not-be-used", "None", "null"],
1054        );
1055
1056        // Two requests on the same blank client each get a distinct UUID v4.
1057        let (_builder, key_again) = empty_client
1058            .request(reqwest::Method::GET, empty_client.endpoint("/payments"))
1059            .expect("request should build");
1060        assert_generated_uuid_v4_idempotency_key(&key_again, &[""]);
1061        assert_ne!(
1062            key_empty, key_again,
1063            "blank sticky key must generate a new UUID per request, not reuse a fixed empty value"
1064        );
1065    }
1066
1067    /// Sticky keys configured on the client are returned unchanged.
1068    #[test]
1069    fn request_preserves_sticky_idempotency_key() {
1070        let expected: &str = "6f7ef3e6-8c2f-4d1c-9f08-5ab7adf56c91";
1071        let client: Client = Client::new(DEFAULT_BASE_URL)
1072            .expect("default client")
1073            .with_idempotency_key(expected);
1074        let (_builder, key) = client
1075            .request(reqwest::Method::POST, client.endpoint("/payments"))
1076            .expect("request should build");
1077        assert_eq!(key, expected);
1078    }
1079
1080    /// Sticky testmode is stored on the client.
1081    #[test]
1082    fn with_testmode_sets_sticky_flag() {
1083        let client: Client = Client::new(DEFAULT_BASE_URL)
1084            .expect("default client")
1085            .with_testmode(true);
1086        assert_eq!(client.testmode(), Some(true));
1087        let client: Client = client.with_testmode(false);
1088        assert_eq!(client.testmode(), Some(false));
1089    }
1090
1091    /// `clear_testmode` restores the default (`None`).
1092    #[test]
1093    fn clear_testmode_restores_none() {
1094        let client: Client = Client::new(DEFAULT_BASE_URL)
1095            .expect("default client")
1096            .with_testmode(true)
1097            .clear_testmode();
1098        assert!(client.testmode().is_none());
1099    }
1100
1101    /// `with_testmode_ref` clones without mutating the original.
1102    #[test]
1103    fn with_testmode_ref_leaves_original_unchanged() {
1104        let client: Client = Client::new(DEFAULT_BASE_URL).expect("default client");
1105        let scoped: Client = client.with_testmode_ref(true);
1106        assert!(client.testmode().is_none());
1107        assert_eq!(scoped.testmode(), Some(true));
1108    }
1109
1110    /// Rejects sticky test mode for live-only business-operation routes.
1111    #[test]
1112    fn rejects_testmode_for_live_only_operations() {
1113        let client: Client = Client::new(DEFAULT_BASE_URL)
1114            .expect("default client")
1115            .with_testmode(false);
1116        let error = client
1117            .reject_testmode_for("list_settlements")
1118            .expect_err("live-only routes must reject configured testmode");
1119
1120        assert!(matches!(error, Error::InvalidRequest(message) if message.contains("testmode")));
1121        assert!(client
1122            .clear_testmode()
1123            .reject_testmode_for("list_settlements")
1124            .is_ok());
1125    }
1126
1127    /// Header-echoed keys are readable on [`ResponseValue`] and [`ResponseEnvelope`].
1128    #[test]
1129    fn response_envelope_exposes_idempotency_key_from_headers() {
1130        let mut headers: reqwest::header::HeaderMap = reqwest::header::HeaderMap::new();
1131        headers.insert(
1132            "idempotency-key",
1133            "6f7ef3e6-8c2f-4d1c-9f08-5ab7adf56c91"
1134                .parse()
1135                .expect("static header value"),
1136        );
1137        let response: ResponseValue<&str> = ResponseValue::new("ok", StatusCode::OK, headers);
1138        assert_eq!(
1139            response.idempotency_key(),
1140            Some("6f7ef3e6-8c2f-4d1c-9f08-5ab7adf56c91")
1141        );
1142        let envelope: ResponseEnvelope<&str> = ResponseEnvelope::from_response_value(response);
1143        assert_eq!(
1144            envelope.idempotency_key(),
1145            Some("6f7ef3e6-8c2f-4d1c-9f08-5ab7adf56c91")
1146        );
1147    }
1148
1149    /// Empty list endpoints return `"count": 0`; `ListCount` must accept zero
1150    /// (OpenAPI previously claimed `minimum: 1`, which produced NonZeroU64).
1151    #[test]
1152    fn list_count_deserializes_zero() {
1153        let count: crate::types::ListCount =
1154            serde_json::from_str("0").expect("count 0 must decode");
1155        assert_eq!(*count, 0);
1156        assert_eq!(count.0, 0);
1157
1158        let nonempty: crate::types::ListCount =
1159            serde_json::from_str("5").expect("count 5 must decode");
1160        assert_eq!(*nonempty, 5);
1161    }
1162
1163    /// List-embedded entities often omit `_links.documentation` even when the
1164    /// OpenAPI schema marks it required for single-resource GET.
1165    #[test]
1166    fn entity_chargeback_links_allow_missing_documentation() {
1167        let json = r#"{
1168            "self": { "href": "https://api.mollie.com/v2/payments/tr_x/chargebacks/chb_x", "type": "application/hal+json" },
1169            "payment": { "href": "https://api.mollie.com/v2/payments/tr_x", "type": "application/hal+json" }
1170        }"#;
1171        let links: crate::types::EntityChargebackLinks =
1172            serde_json::from_str(json).expect("chargeback links without documentation");
1173        assert!(links.documentation.is_none());
1174        assert_eq!(
1175            links.payment.href,
1176            "https://api.mollie.com/v2/payments/tr_x"
1177        );
1178    }
1179
1180    /// Same omission pattern as customers/chargebacks for refund list embeds.
1181    #[test]
1182    fn entity_refund_links_allow_missing_documentation() {
1183        let json = r#"{
1184            "self": { "href": "https://api.mollie.com/v2/payments/tr_x/refunds/re_x", "type": "application/hal+json" },
1185            "payment": { "href": "https://api.mollie.com/v2/payments/tr_x", "type": "application/hal+json" }
1186        }"#;
1187        let links: crate::types::EntityRefundLinks =
1188            serde_json::from_str(json).expect("refund links without documentation");
1189        assert!(links.documentation.is_none());
1190    }
1191
1192    /// Live refunds return `"metadata": null`; decode as `None` (not required Metadata).
1193    #[test]
1194    fn entity_refund_allows_null_metadata() {
1195        let json = r#"{
1196            "resource": "refund",
1197            "id": "re_test",
1198            "mode": "live",
1199            "amount": { "value": "1.00", "currency": "EUR" },
1200            "status": "refunded",
1201            "createdAt": "2026-01-22T10:39:23+00:00",
1202            "description": "Credit",
1203            "metadata": null,
1204            "paymentId": "tr_test",
1205            "_links": {
1206                "self": { "href": "https://api.mollie.com/v2/payments/tr_test/refunds/re_test", "type": "application/hal+json" },
1207                "payment": { "href": "https://api.mollie.com/v2/payments/tr_test", "type": "application/hal+json" }
1208            }
1209        }"#;
1210        let refund: crate::types::EntityRefund =
1211            serde_json::from_str(json).expect("refund with null metadata");
1212        assert!(refund.metadata.is_none());
1213    }
1214}