Skip to main content

Crate paykit

Crate paykit 

Source
Expand description

Payment provider abstraction with a Mollie implementation.

The crate is built around the PaymentProvider trait, which is object-safe so providers can be injected as Arc<dyn PaymentProvider>.

§Security

Verifying a payment is not the same as verifying an order. This crate can tell you what the provider believes about a payment; it cannot tell you the payment matches what you charged. Always compare the amount and currency against your own stored order total before treating a payment as settled, and look orders up by the provider payment id you stored yourself — never by a reference or metadata value echoed back by the provider.

PaymentProvider::fetch_verified enforces both halves of that check that this crate can enforce — the money and the settled status. The order binding remains the caller’s responsibility.


The README is included below, which makes its usage example a doctest: the first code a crates.io visitor copies is compiled on every CI run rather than left to rot against the real API.

§paykit

crates.io docs.rs CI MSRV license

A payment-provider abstraction for Rust, with a Mollie implementation.

paykit is built around one object-safe trait, PaymentProvider, so an application can hold an Arc<dyn PaymentProvider> and swap providers — or substitute a fake in tests — without the call sites changing. The core types (Money, Currency, Payment, PaymentStatus) carry no HTTP dependency; the transport arrives with a provider feature.

  • Object-safe trait, injectable as Arc<dyn PaymentProvider>
  • Integer-cent Money — no floats anywhere near an amount
  • Transport-agnostic core: --no-default-features builds without reqwest
  • #![forbid(unsafe_code)], #![deny(missing_docs)]

§Install

[dependencies]
paykit = "0.1"

Default features are mollie and rustls-tls. For the core traits and types only:

[dependencies]
paykit = { version = "0.1", default-features = false }

§Usage

use std::sync::Arc;
use std::time::Duration;

use paykit::{Currency, CreatePayment, Error, Money, PaymentProvider, PaymentStatus};
use paykit::providers::mollie::MollieProvider;

// Build the client explicitly rather than using `reqwest::Client::new()`: the default
// has no total-request timeout, no connect timeout, and follows up to 10 redirects.
// None of that is what you want for a client that carries this provider's API key on
// every request — an unbounded timeout means a wedged connection hangs your request
// indefinitely, and following redirects could hand the `Authorization` header to
// whatever host a redirect response names. Share one `Client` across the application
// so connection pooling and TLS session reuse are shared too.
let http = paykit::reqwest::Client::builder()
    .connect_timeout(Duration::from_secs(5))
    .timeout(Duration::from_secs(10))
    .redirect(paykit::reqwest::redirect::Policy::none())
    .build()
    .expect("client configuration is valid");
let provider: Arc<dyn PaymentProvider> =
    Arc::new(MollieProvider::with_client("test_xxxxxxxx", http)?);

// Amounts are integer minor units, never floats. The decimal string sent to the
// provider follows the currency's own ISO 4217 exponent — "19.99" for EUR, but
// "1999" for a zero-decimal currency like JPY.
// Your own stored order total — never the payment's own reported amount, which is
// exactly what `fetch_verified` below is there to check against.
let order_total = Money::from_minor(1999, Currency::EUR);

let request = CreatePayment::new(
    order_total,
    "Order #1234",
    "https://shop.example/checkout/return",
)
.with_webhook_url("https://shop.example/webhooks/mollie")
.with_reference("order-1234");

let payment = provider.create_payment(request).await?;

// Send the customer here to pay.
println!("{:?}", payment.checkout_url);

// Later, from your webhook handler: trust only the payment id from the body, then
// re-fetch the payment from the provider via `fetch_verified` — the crate's headline
// security control. It fails unless the fetched payment's amount and currency match
// `order_total` *and* its status is paid, so a `VerifiedPayment` is the only value
// safe to treat as a settled order.
match provider.fetch_verified(&payment.id, order_total).await {
    Ok(verified) => { /* mark the order paid, looked up by `verified.payment().id` */ }
    // `..` is required: these are `#[non_exhaustive]` struct variants, so matching their
    // fields from outside this crate without it is a compile error.
    Err(Error::AmountMismatch { expected, actual, .. }) => {
        // Under/overpayment or wrong currency: fail closed, raise for manual review.
        let _ = (expected, actual);
    }
    Err(Error::NotPaid { status, .. }) => match status {
        PaymentStatus::Failed | PaymentStatus::Cancelled | PaymentStatus::Expired => {
            /* release stock */
        }
        // Anything else, including a status this crate does not recognise: defer.
        _ => {}
    },
    Err(err) => {
        // `Display` deliberately does not render a provider's `title`/`detail` — a
        // compromised or misconfigured provider echoing request content back into
        // either is a realistic credential-leak path (see `Error`'s docs), so nothing
        // pulls them into a log line by default. Reach for them explicitly, here via
        // `Error::title`, only once you know the context you are logging into is safe
        // for provider-supplied text.
        eprintln!("payment verification failed: {err} (title: {:?})", err.title());
        return Err(err);
    }
}

§Feature flags

FeatureDefaultDescription
mollieyesThe Mollie provider implementation. Pulls in reqwest and serde_json.
rustls-tlsyesTLS via rustls. No system OpenSSL needed; the usual choice.
native-tlsnoTLS via the platform’s native stack (OpenSSL / Schannel / Secure Transport).
serdenoSerialize/Deserialize on Payment, Money, PaymentStatus, Refund, RefundStatus, for persisting or forwarding them.

A provider feature needs a TLS backend. Enabling mollie with neither rustls-tls nor native-tls is a compile error rather than a runtime surprise — without one, the crate still builds but every HTTPS request fails once you are in production.

Cargo features are additive across the whole dependency graph, and TLS backend is no exception. If your application depends on paykit with only rustls-tls, but some other crate in your dependency tree enables paykit/native-tls (directly or transitively), your build silently gets both backends compiled in and, depending on the underlying HTTP client, may end up using native-tls instead of the one you configured. Cargo has no notion of “my crate’s choice wins” — the union of every enabled feature in the graph is what gets built. If this matters to you, audit cargo tree -e features -i paykit rather than assuming your own Cargo.toml is the last word.

Turning off default features gives you PaymentProvider, Money, Payment and friends with no HTTP stack at all, which is what you want when implementing the trait against your own transport or a test double.

§reqwest is a public dependency

This is deliberate and it has a versioning consequence, so it is stated up front rather than buried in the API docs.

Provider constructors accept a caller-supplied reqwest::Client (MollieProvider::with_client) so that connection pooling, timeouts, proxy configuration and TLS settings are shared with the rest of your application instead of paykit quietly opening a second pool. reqwest types therefore appear in the public API, and the crate re-exports paykit::reqwest so you can construct a client from the exact version this crate links against.

A breaking reqwest release is a breaking release of paykit. When reqwest goes to 0.13, paykit will need a major (pre-1.0: minor) version bump, because a consumer passing a reqwest 0.12 Client into a paykit built against 0.13 gets a type error, not a deprecation warning. Pin accordingly, and expect this crate’s version to track reqwest’s breaking changes as well as its own.

If you want to avoid that coupling entirely, depend on paykit with default-features = false and implement PaymentProvider over your own HTTP client.

§Security

Read this section before shipping. The failure modes below are the ones that lose money or cancel real orders, and none of them are things this crate can prevent for you.

Verifying a payment is not the same as verifying an order. paykit reports what the provider believes about a payment. It cannot know what you intended to charge. Before you treat a payment as settled, compare the amount and the currency against your own stored order total. A Paid status on a 1.00 EUR payment against a 100.00 EUR order is still Paid. If they do not match, fail closed: acknowledge the webhook, do not confirm the order, and raise it for manual review.

Look orders up by the payment id you stored yourself. Never by a reference, description or metadata value echoed back by the provider. Those fields round-trip through a system you do not control and, in some flows, through input a customer can influence. The payment id you persisted when you created the payment is the only safe join key.

Treat an unrecognised payment status as “defer, take no action.” Not as a failure, and not as a cancel path. Providers add statuses; a match arm with a catch-all that releases stock or refunds will happily cancel real, paid orders the day Mollie ships a new state. The correct catch-all does nothing and leaves the order for the next webhook or for your reconciliation sweep to resolve.

Further integrator responsibilities — forward-only status transitions under a row lock, webhook response discipline, reconciliation, rate limiting — are listed in SECURITY.md.

To report a vulnerability in paykit itself, email stefan@hofman-consulting.nl. Please do not open a public issue.

§Minimum supported Rust version

Rust 1.88. The MSRV is verified in CI against the committed Cargo.lock.

Raising the MSRV is treated as a semver-visible change and will come with at least a minor version bump while this crate is pre-1.0.

§Contributing

Issues and pull requests are welcome. Before opening a PR, please run:

cargo fmt --all
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo test --no-default-features

The last one matters: the core is meant to be transport-agnostic, and it is easy to leak a reqwest reference into it without noticing.

§License

Licensed under either of

at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.

Re-exports§

pub use error::Error;
pub use money::Currency;
pub use money::Money;
pub use money::ParseCurrencyError;
pub use money::ParseMoneyError;
pub use payment::CreatePayment;
pub use payment::Payment;
pub use payment::PaymentStatus;
pub use payment::Refund;
pub use payment::RefundRequest;
pub use payment::RefundStatus;
pub use payment::VerifiedPayment;
pub use provider::PaymentProvider;
pub use providers::mollie::MollieProvider;mollie
pub use reqwest;mollie

Modules§

error
Error types returned by PaymentProvider implementations.
money
ISO 4217 currencies and minor-unit money amounts with checked arithmetic.
payment
Payment and refund domain types.
provider
The PaymentProvider trait, the crate’s central abstraction.
providers
Concrete PaymentProvider implementations.