Mollie
Mollie's Payments API without an SDK: payments and refunds with amounts in the exact decimals Mollie insists on, customers, mandates and recurring charges, lists followed through their next links, and webhooks taken as Mollie means them: only an id comes in, and the payment is fetched back to learn what happened.
Version 1.0.0 · 18 functions · 2 crates
Functions
What use mollie.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use mollie_amount, mollie_payment_create from mollie (Quillo 0.6).
mollie_amount(amount: str, currency: str) → anyor an errorAn amount as Mollie wants it, a string with exactly the currency's decimals: mollie_amount("10", "EUR") is { currency: "EUR", value: "10.00" }, ("1000", "JPY") keeps none. More decimals than that are an error.
mollie_payment_create(amount: any, description: str, redirect_url: str, params: any) → anyor an errorMakes a payment: amount from mollie_amount, redirect_url where the customer comes back. params adds webhookUrl, method, metadata, locale, idempotency_key... Gives the payment with checkout_url: send the customer there.
mollie_payment(id: str) → anyor an errorOne payment by id (tr_...), with checkout_url.
mollie_payment_cancel(id: str) → anyor an errorCancels a payment whose isCancelable is true.
mollie_payments(max_items: int) → listor an errorPayments, newest first, through every page; max_items caps them (0: all).
mollie_refund(payment_id: str, amount: any, description: str) → anyor an errorRefunds amount of a payment. description may be "". Gives the refund; Mollie refuses more than is left.
mollie_refunds(payment_id: str) → listor an errorA payment's refunds.
mollie_refund_cancel(payment_id: str, refund_id: str) → boolor an errorCancels a refund still queued or pending.
mollie_customer_create(fields: any) → anyor an errorMakes a customer: fields are name, email, locale, metadata.
mollie_customer(id: str) → anyor an errorOne customer by id (cst_...).
mollie_customers(max_items: int) → listor an errorCustomers through every page; max_items caps them (0: all).
mollie_first_payment(customer_id: str, amount: any, description: str, redirect_url: str, params: any) → anyor an errorThe first payment of a recurring customer: the customer pays once by hand and Mollie makes a mandate of it. Otherwise as mollie_payment_create.
mollie_charge(customer_id: str, amount: any, description: str, mandate_id: str, params: any) → anyor an errorA charge on a customer's mandate, with no one present: give mandate_id, or "" to let Mollie pick a valid one. It starts pending and ends by webhook. Give params.idempotency_key so a retry cannot charge twice.
mollie_mandate_create(customer_id: str, fields: any) → anyor an errorMakes a mandate directly: fields as Mollie documents them (method: "directdebit", consumerName, consumerAccount).
mollie_mandates(customer_id: str) → listor an errorA customer's mandates, each with a status of valid, pending or invalid.
mollie_valid_mandate(customer_id: str) → anyor an errorThe first mandate that can be charged now, or null.
mollie_mandate_revoke(customer_id: str, mandate_id: str) → boolor an errorRevokes a mandate.
mollie_webhook(body: str) → anyor an errorHandles a webhook's raw form body (id=tr_...). Nothing in it can be believed, so the payment is fetched back by id. Gives { known, id, status, paid, payment }; an unknown id is known: false, not an error: Mollie's advice is to answer 200 either way, so a stranger learns nothing.
Get it
$ quillo install mollie # mollie.qwer into this folder$ quillo install mollie --example # and its example, mollie.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/mollie.qwer$ curl -O https://quillolang.org/plugins/files/mollie.qweThen, in your program, all of it or only what you call:
use mollie.qweruse mollie_amount, mollie_payment_create from mollie // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
ureq = "2"serde_json = { version = "1", features = ["preserve_order"] }Example
// Taking payments with Mollie: a payment and its checkout link, refunds, the webhook that only says// "look at this id", a customer's first payment, a mandate and a recurring charge.//// MOLLIE_API_KEY=test_... quillo run mollie.qwe// then open /pay, /hook, /refund, /recurring, /lists, /amounts and /failures//// MOLLIE_API_URL reaches a test server. Mollie's webhookUrl must be reachable from the internet, so// point it at POST /mollie/webhook on a public address; a real one only ever sends `id=tr_...`.use "mollie.qwer"route GET /pay { let p = mollie_payment_create(mollie_amount("10", "EUR")?, "Order 1001", "https://shop.example/back", { webhookUrl: "https://shop.example/mollie/webhook", metadata: { order: 1001 } })? let read = mollie_payment(p.id)? let gone = mollie_payment_create(mollie_amount("5", "EUR")?, "Changed my mind", "https://shop.example/back", {})? { state: p.status, sends_to: contains(p.checkout_url, p.id), value: read.amount.value, order: read.metadata.order, cancelled: mollie_payment_cancel(gone.id)?.status }}route GET /once { let a = mollie_payment_create(mollie_amount("3", "EUR")?, "Same key", "https://shop.example/back", { idempotency_key: "key-1" })? let b = mollie_payment_create(mollie_amount("3", "EUR")?, "Same key", "https://shop.example/back", { idempotency_key: "key-1" })? { same: a.id == b.id }}route POST /mollie/webhook { let hook = mollie_webhook(request.body)? if hook.paid { print("paid", hook.id) } // Always a 200: an unknown id is no one's business. "ok"}// What comes in is only an id: what it means is read back from Mollie.route GET /hook { let p = mollie_payment_create(mollie_amount("12.50", "EUR")?, "Order 1002", "https://shop.example/back", { method: "creditcard" })? let seen = mollie_webhook(`id=${p.id}`)? { known: seen.known, paid: seen.paid, status: seen.status, unknown: mollie_webhook("id=tr_nosuch")?.known, empty: mollie_webhook("")?.known, odd: mollie_webhook("id=../../customers")?.known, }}route GET /refund { let p = mollie_payment_create(mollie_amount("20", "EUR")?, "Order 1003", "https://shop.example/back", { method: "creditcard" })? mollie_payment(p.id)? let back = mollie_refund(p.id, mollie_amount("5", "EUR")?, "Chipped mug")? let other = mollie_refund(p.id, mollie_amount("1", "EUR")?, "")? { state: back.status, count: len(mollie_refunds(p.id)?), cancelled: mollie_refund_cancel(p.id, other.id)?, left: len(mollie_refunds(p.id)?), too_much: mollie_refund(p.id, mollie_amount("99", "EUR")?, "").error, }}route GET /recurring { let c = mollie_customer_create({ name: "Ada Lovelace", email: "ada@example.com" })? let first = mollie_first_payment(c.id, mollie_amount("0.01", "EUR")?, "First payment", "https://shop.example/back", { method: "creditcard" })? let no_mandate = mollie_charge(c.id, mollie_amount("9.99", "EUR")?, "Too soon", "", {}).error mollie_payment(first.id)? let mandate = mollie_valid_mandate(c.id)? let charge = mollie_charge(c.id, mollie_amount("9.99", "EUR")?, "November", mandate.id, { idempotency_key: "nov-1" })? let revoked = mollie_mandate_revoke(c.id, mandate.id)? { name: mollie_customer(c.id)?.name, sequence: first.sequenceType, no_mandate: no_mandate, mandate: mandate.status, charge: [charge.status, charge.sequenceType, charge.checkout_url], revoked: revoked, now: mollie_valid_mandate(c.id)?, }}route GET /lists = { all: len(mollie_payments(0)?) > 600, capped: len(mollie_payments(70)?), customers: len(mollie_customers(0)?) > 300,}route GET /amounts = { euros: mollie_amount("10", "eur")?, cents: mollie_amount("0.5", "EUR")?, yen: mollie_amount("1000", "JPY")?, too_fine: mollie_amount("1.005", "EUR").error, float_like: mollie_amount("ten", "EUR").error,}route GET /failures = { missing: mollie_payment("tr_nosuch").error, bad_amount: mollie_payment_create(mollie_amount("0", "EUR")?, "Nothing", "https://x.example", {}).error, no_redirect: mollie_payment_create(mollie_amount("1", "EUR")?, "x", "", {}).error, not_text: mollie_payment_create({ currency: "EUR", value: 10 }, "x", "y", {}).error, waited: mollie_payment_create(mollie_amount("2", "EUR")?, "busy", "https://x.example", { idempotency_key: "busy-1" })?.status,}$ quillo run mollie.qwe$ curl 'localhost:8080/pay'$ curl 'localhost:8080/once'$ curl 'localhost:8080/hook'$ curl 'localhost:8080/refund'$ curl 'localhost:8080/recurring'$ curl 'localhost:8080/lists'$ curl 'localhost:8080/amounts'$ curl 'localhost:8080/failures'It talks to the Redis that REDIS_URL names, else Redis on this machine. The site's test runs it against a stand-in.
Source
//! Mollie's Payments API without an SDK: payments with the exact decimals Mollie insists on, refunds, customers, mandates and recurring charges, lists followed through their next links, Mollie's problem answers read, and webhooks handled as Mollie means them: only an id comes in, and the payment is fetched back to learn what happened.//! version: 1.0.0//!//! [dependencies]//! ureq = "2"//! serde_json = { version = "1", features = ["preserve_order"] }use serde_json::{json, Value};use std::time::Duration;// MOLLIE_API_KEY is a test_ or live_ key (or an access_ token); MOLLIE_API_URL the API// (https://api.mollie.com/v2 when not set).const ZERO: &[&str] = &["JPY", "ISK"];fn setting(k: &str) -> Option<String> { std::env::var(k).ok().filter(|v| !v.is_empty())}fn api() -> String { setting("MOLLIE_API_URL").unwrap_or_else(|| "https://api.mollie.com/v2".into()).trim_end_matches('/').to_string()}fn enc(s: &str) -> String { s.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{:02X}", b) }).collect()}fn token() -> Result<String, String> { let t = setting("MOLLIE_API_KEY").ok_or("MOLLIE_API_KEY is not set")?; if !(t.starts_with("test_") || t.starts_with("live_") || t.starts_with("access_")) { return Err("a Mollie key starts with test_ or live_, a token with access_".into()); } Ok(t)}fn problem(status: u16, text: &str) -> String { let v: Value = serde_json::from_str(text).unwrap_or(Value::Null); let mut t = format!("Mollie {}", status); if let Some(title) = v["title"].as_str() { t.push_str(&format!(" {}", title)); } match v["detail"].as_str() { Some(d) => t.push_str(&format!(": {}", d)), None => t.push_str(&format!(": {}", text.trim().chars().take(300).collect::<String>())), } if let Some(f) = v["field"].as_str() { t.push_str(&format!(" (field {})", f)); } t}// `url` is whole, as a next link is. A write without a key is not repeated.fn fetch(method: &str, url: &str, body: Option<&Value>, idempotency: &str) -> Result<Value, String> { let t = token()?; let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(30)).build(); let tries = if method == "GET" || !idempotency.is_empty() { 4u32 } else { 1 }; for attempt in 0..tries { let mut req = agent.request(method, url).set("Authorization", &format!("Bearer {}", t)).set("Accept", "application/hal+json"); if !idempotency.is_empty() { req = req.set("Idempotency-Key", idempotency); } let res = match body { Some(b) => req.set("Content-Type", "application/json").send_string(&b.to_string()), None => req.call(), }; match res { Ok(r) => { let text = r.into_string().map_err(|e| e.to_string())?; return if text.trim().is_empty() { Ok(Value::Null) } else { serde_json::from_str(&text).map_err(|_| format!("Mollie's answer is not JSON: {}", text.chars().take(200).collect::<String>())) }; } Err(ureq::Error::Status(429 | 502 | 503 | 504, r)) if attempt + 1 < tries => { let wait = r.header("Retry-After").and_then(|s| s.parse::<u64>().ok()).map_or(300u64 << attempt, |s| s * 1000); std::thread::sleep(Duration::from_millis(wait.min(30_000))); } Err(ureq::Error::Status(c, r)) => return Err(problem(c, &r.into_string().unwrap_or_default())), Err(e) => return Err(format!("cannot reach Mollie at {}: {}", api(), e)), } } Err("Mollie stayed busy through four tries".into())}fn call(method: &str, path: &str, body: Option<&Value>) -> Result<Value, String> { fetch(method, &format!("{}{}", api(), path), body, "")}// A list's items are in _embedded under its own name, and the next page is a whole link.fn walk(path: &str, field: &str, max_items: i64) -> Result<Vec<Value>, String> { let mut out = vec![]; let mut url = format!("{}{}{}limit=250", api(), path, if path.contains('?') { "&" } else { "?" }); loop { let v = fetch("GET", &url, None, "")?; out.extend(v["_embedded"][field].as_array().cloned().unwrap_or_default()); if max_items > 0 && out.len() as i64 >= max_items { out.truncate(max_items as usize); return Ok(out); } match v["_links"]["next"]["href"].as_str() { Some(n) => url = n.to_string(), None => return Ok(out), } }}/// An amount as Mollie wants it, a string with exactly the currency's decimals: `mollie_amount("10", "EUR")` is `{ currency: "EUR", value: "10.00" }`, `("1000", "JPY")` keeps none. More decimals than that are an error.pub fn mollie_amount(amount: &str, currency: &str) -> Result<serde_json::Value, String> { let c = currency.to_uppercase(); if c.len() != 3 || !c.bytes().all(|b| b.is_ascii_uppercase()) { return Err(format!("`{}` is not a three-letter currency code", currency)); } let places = if ZERO.contains(&c.as_str()) { 0 } else { 2 }; let a = amount.trim(); let (whole, frac) = a.split_once('.').unwrap_or((a, "")); if whole.is_empty() || !whole.bytes().all(|b| b.is_ascii_digit()) || !frac.bytes().all(|b| b.is_ascii_digit()) { return Err(format!("`{}` is not an amount", amount)); } let frac = frac.trim_end_matches('0'); if frac.len() > places { return Err(format!("{} has {} decimals; {} has {} at Mollie", amount, frac.len(), c, places)); } let value = if places == 0 { whole.to_string() } else { format!("{}.{}{}", whole, frac, "0".repeat(places - frac.len())) }; Ok(json!({ "currency": c, "value": value }))}fn take_key(b: &mut Value) -> String { b.as_object_mut().and_then(|m| m.remove("idempotency_key")).and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default()}fn create(path: &str, mut b: Value) -> Result<Value, String> { let idem = take_key(&mut b); fetch("POST", &format!("{}{}", api(), path), Some(&b), &idem)}fn summary(p: Value) -> Value { let checkout = p["_links"]["checkout"]["href"].clone(); let mut p = p; p["checkout_url"] = checkout; p}/// Makes a payment: `amount` from mollie_amount, `redirect_url` where the customer comes back. `params` adds `webhookUrl`, `method`, `metadata`, `locale`, `idempotency_key`... Gives the payment with `checkout_url`: send the customer there.pub fn mollie_payment_create(amount: serde_json::Value, description: &str, redirect_url: &str, params: serde_json::Value) -> Result<serde_json::Value, String> { if !amount["value"].is_string() { return Err("the amount must be { currency, value } with a text value: see mollie_amount".into()); } if description.is_empty() { return Err("a payment needs a description".into()); } let mut b = if params.is_object() { params } else { json!({}) }; b["amount"] = amount; b["description"] = json!(description); if !redirect_url.is_empty() { b["redirectUrl"] = json!(redirect_url); } Ok(summary(create("/payments", b)?))}/// One payment by id (tr_...), with `checkout_url`.pub fn mollie_payment(id: &str) -> Result<serde_json::Value, String> { Ok(summary(call("GET", &format!("/payments/{}", enc(id)), None)?))}/// Cancels a payment whose `isCancelable` is true.pub fn mollie_payment_cancel(id: &str) -> Result<serde_json::Value, String> { call("DELETE", &format!("/payments/{}", enc(id)), None)}/// Payments, newest first, through every page; `max_items` caps them (0: all).pub fn mollie_payments(max_items: i64) -> Result<Vec<serde_json::Value>, String> { walk("/payments", "payments", max_items)}/// Refunds `amount` of a payment. `description` may be "". Gives the refund; Mollie refuses more than is left.pub fn mollie_refund(payment_id: &str, amount: serde_json::Value, description: &str) -> Result<serde_json::Value, String> { let mut b = json!({ "amount": amount }); if !description.is_empty() { b["description"] = json!(description); } call("POST", &format!("/payments/{}/refunds", enc(payment_id)), Some(&b))}/// A payment's refunds.pub fn mollie_refunds(payment_id: &str) -> Result<Vec<serde_json::Value>, String> { walk(&format!("/payments/{}/refunds", enc(payment_id)), "refunds", 0)}/// Cancels a refund still `queued` or `pending`.pub fn mollie_refund_cancel(payment_id: &str, refund_id: &str) -> Result<bool, String> { call("DELETE", &format!("/payments/{}/refunds/{}", enc(payment_id), enc(refund_id)), None)?; Ok(true)}/// Makes a customer: `fields` are `name`, `email`, `locale`, `metadata`.pub fn mollie_customer_create(fields: serde_json::Value) -> Result<serde_json::Value, String> { create("/customers", fields)}/// One customer by id (cst_...).pub fn mollie_customer(id: &str) -> Result<serde_json::Value, String> { call("GET", &format!("/customers/{}", enc(id)), None)}/// Customers through every page; `max_items` caps them (0: all).pub fn mollie_customers(max_items: i64) -> Result<Vec<serde_json::Value>, String> { walk("/customers", "customers", max_items)}/// The first payment of a recurring customer: the customer pays once by hand and Mollie makes a mandate of it. Otherwise as mollie_payment_create.pub fn mollie_first_payment(customer_id: &str, amount: serde_json::Value, description: &str, redirect_url: &str, params: serde_json::Value) -> Result<serde_json::Value, String> { let mut p = if params.is_object() { params } else { json!({}) }; p["customerId"] = json!(customer_id); p["sequenceType"] = json!("first"); mollie_payment_create(amount, description, redirect_url, p)}/// A charge on a customer's mandate, with no one present: give `mandate_id`, or "" to let Mollie pick a valid one. It starts `pending` and ends by webhook. Give `params.idempotency_key` so a retry cannot charge twice.pub fn mollie_charge(customer_id: &str, amount: serde_json::Value, description: &str, mandate_id: &str, params: serde_json::Value) -> Result<serde_json::Value, String> { let mut p = if params.is_object() { params } else { json!({}) }; p["customerId"] = json!(customer_id); p["sequenceType"] = json!("recurring"); if !mandate_id.is_empty() { p["mandateId"] = json!(mandate_id); } mollie_payment_create(amount, description, "", p)}/// Makes a mandate directly: `fields` as Mollie documents them (`method`: "directdebit", `consumerName`, `consumerAccount`).pub fn mollie_mandate_create(customer_id: &str, fields: serde_json::Value) -> Result<serde_json::Value, String> { call("POST", &format!("/customers/{}/mandates", enc(customer_id)), Some(&fields))}/// A customer's mandates, each with a `status` of valid, pending or invalid.pub fn mollie_mandates(customer_id: &str) -> Result<Vec<serde_json::Value>, String> { walk(&format!("/customers/{}/mandates", enc(customer_id)), "mandates", 0)}/// The first mandate that can be charged now, or null.pub fn mollie_valid_mandate(customer_id: &str) -> Result<serde_json::Value, String> { Ok(mollie_mandates(customer_id)?.into_iter().find(|m| m["status"] == "valid").unwrap_or(Value::Null))}/// Revokes a mandate.pub fn mollie_mandate_revoke(customer_id: &str, mandate_id: &str) -> Result<bool, String> { call("DELETE", &format!("/customers/{}/mandates/{}", enc(customer_id), enc(mandate_id)), None)?; Ok(true)}/// Handles a webhook's raw form `body` (`id=tr_...`). Nothing in it can be believed, so the payment is fetched back by id. Gives { known, id, status, paid, payment }; an unknown id is `known: false`, not an error: Mollie's advice is to answer 200 either way, so a stranger learns nothing.pub fn mollie_webhook(body: &str) -> Result<serde_json::Value, String> { let id = body.split('&').find_map(|p| p.strip_prefix("id=")).unwrap_or("").to_string(); if id.is_empty() { return Ok(json!({ "known": false, "id": Value::Null, "status": Value::Null, "paid": false, "payment": Value::Null })); } if !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') { return Ok(json!({ "known": false, "id": Value::Null, "status": Value::Null, "paid": false, "payment": Value::Null })); } match mollie_payment(&id) { Ok(p) => Ok(json!({ "known": true, "id": id, "status": p["status"], "paid": p["paidAt"].is_string() || p["status"] == "paid", "payment": p })), Err(e) if e.starts_with("Mollie 404") => Ok(json!({ "known": false, "id": id, "status": Value::Null, "paid": false, "payment": Value::Null })), Err(e) => Err(e), }}Related
adyen.qwer · 1.0.0Adyen's Checkout API without an SDK: sessions for Drop-in, payments with their result codes turned into paid, failed, pending or waiting on the shopper, 3-D Secure details sent on, captures, refunds, cancels and reversals, amounts by Adyen's own table of minor units, and each webhook notification's HMAC checked.2 views · 7 downloadsPaddle Billing paddle.qwer · 1.0.0Paddle Billing's API without an SDK: prices, transactions and subscriptions, lists followed through their next links, subscription changes with the proration Paddle insists on, pauses, resumes and cancels, money as Paddle's strings of the lowest denomination, and the Paddle-Signature header checked with its timestamp and rotating keys.5 views · 8 downloadsPayPal paypal.qwer · 1.0.0PayPal's Orders API without an SDK: an OAuth token reused until it expires and renewed on a 401, orders created and captured, refunds, webhooks checked through PayPal's verify call on their exact bytes, and retries made safe with PayPal-Request-Id, each error given with PayPal's issue and debug id.3 views · 6 downloadsRazorpay razorpay.qwer · 1.0.0Razorpay's API without an SDK: orders in paise, the signature Checkout hands the page checked against your own order, payments captured and refunded, lists walked by count and skip, webhook signatures, and rupees written with lakh and crore grouping.3 views · 7 downloadsSquare square.qwer · 1.0.0Square's API without an SDK: card payments taken at once or held and completed later, refunds, customers and catalog items, lists followed through their cursors, an idempotency key on every write that a retry reuses, money in each currency's smallest unit, and webhook signatures checked over the notification URL and body.2 views · 6 downloadsWebhooks webhooks.qwer · 1.0.0Check that a webhook came from Stripe, GitHub, Slack, Shopify or a Standard Webhooks sender, with timestamp tolerance; and sign your own.5 views · 10 downloadsThread 0 posts
On Mollie: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.